Electron: Building for Mac, Windows and Linux

Intro:

Electron is a framework that lets you build desktop applications using the same three technologies you already use on the web: HTML, CSS and JavaScript. It bundles Chromium (for rendering the interface) and Node.js (for the system-level work), so a single codebase can run as a native-feeling app on Windows, macOS and Linux. Visual Studio Code, Slack and Discord are all built this way.

Writing the app is the easy half. The half that usually trips people up is packaging: turning a folder of JavaScript files into an .exe installer, a .dmg disk image and an .AppImage or .deb package. That is what we are doing here. We will build a tiny demo app first, and then compile it for all three platforms with electron-builder.

Getting Started:

You only need Node.js (version 18 or newer) and npm. Check what you have:

bash
node -v
npm -v

Create the project folder and initialize it:

bash
mkdir electron-demo
cd electron-demo
npm init -y

Now install Electron and the packaging tool. Both are development dependencies — they are used to build the app, not shipped as part of it:

bash
npm install --save-dev electron electron-builder

This is the structure we are going to end up with:

electron-demo/ ├─ build/ │ ├─ icon.ico (Windows, 256×256) │ ├─ icon.icns (macOS) │ └─ icon.png (Linux, 512×512) ├─ renderer/ │ ├─ index.html │ ├─ styles.css │ └─ renderer.js ├─ main.js ├─ preload.js └─ package.json

The Demo App:

An Electron app has two kinds of processes. The main process is Node.js: it creates windows and talks to the operating system. The renderer process is the web page inside the window. They are isolated from each other, and a preload script is the bridge that exposes a controlled set of functions from one side to the other.

main.js

This creates the window and answers one message coming from the interface:

javascript
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 720,
    height: 480,
    title: 'Electron Demo',
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  });

  win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
}

// Handler for the message sent by the renderer.
ipcMain.handle('get-info', () => {
  return {
    appVersion: app.getVersion(),
    electron: process.versions.electron,
    node: process.versions.node,
    chrome: process.versions.chrome,
    platform: process.platform,
    arch: process.arch
  };
});

app.whenReady().then(() => {
  createWindow();

  // On macOS, re-create the window when the dock icon is clicked.
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

// On Windows and Linux, closing every window quits the app.
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

preload.js

Instead of giving the web page full access to Node, we expose exactly one function:

javascript
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  getInfo: () => ipcRenderer.invoke('get-info')
});

renderer/index.html

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self'">
  <title>Electron Demo</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <main>
    <h1>Hello from Electron</h1>
    <p id="version">Loading…</p>
    <p id="system"></p>
    <button id="refresh">Read system info</button>
  </main>
  <script src="renderer.js"></script>
</body>
</html>

renderer/styles.css

css
body {
  margin: 0;
  height: 100vh;
  display: grid;
  place-items: center;
  font-family: system-ui, "Segoe UI", Roboto, sans-serif;
  background: #f5f6f8;
  color: #22262b;
}
main { text-align: center; }
h1 { margin: 0 0 10px; font-size: 26px; }
p  { margin: 4px 0; color: #4b5563; }
button {
  margin-top: 16px;
  padding: 8px 18px;
  font-size: 14px;
  border: 1px solid #c9ced6;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
}
button:hover { background: #eef1f5; }

renderer/renderer.js

javascript
async function load() {
  const info = await window.api.getInfo();

  document.getElementById('version').textContent =
    'App version ' + info.appVersion;

  document.getElementById('system').textContent =
    info.platform + ' / ' + info.arch +
    ' • Electron ' + info.electron +
    ' • Node ' + info.node +
    ' • Chrome ' + info.chrome;
}

document.getElementById('refresh').addEventListener('click', load);
load();

package.json

Two things matter here: the scripts block (how we launch and build) and the build block (how electron-builder packages each platform):

json
{
  "name": "electron-demo",
  "version": "1.0.0",
  "description": "A simple cross-platform Electron demo",
  "author": "Luis A. Sierra",
  "license": "MIT",
  "main": "main.js",
  "scripts": {
    "start": "electron .",
    "dist:win": "electron-builder --win",
    "dist:mac": "electron-builder --mac",
    "dist:linux": "electron-builder --linux",
    "dist:all": "electron-builder -mwl"
  },
  "build": {
    "appId": "com.luissierra.electrondemo",
    "productName": "Electron Demo",
    "directories": {
      "output": "dist",
      "buildResources": "build"
    },
    "files": [
      "main.js",
      "preload.js",
      "renderer/**/*",
      "package.json"
    ],
    "win": {
      "target": ["nsis", "portable"],
      "icon": "build/icon.ico"
    },
    "nsis": {
      "oneClick": false,
      "allowToChangeInstallationDirectory": true,
      "createDesktopShortcut": true
    },
    "mac": {
      "target": [
        { "target": "dmg", "arch": ["x64", "arm64"] },
        { "target": "zip", "arch": ["x64", "arm64"] }
      ],
      "icon": "build/icon.icns",
      "category": "public.app-category.developer-tools"
    },
    "linux": {
      "target": ["AppImage", "deb"],
      "icon": "build/icon.png",
      "category": "Utility",
      "maintainer": "Luis A. Sierra <you@example.com>"
    }
  },
  "devDependencies": {
    "electron": "^33.2.0",
    "electron-builder": "^25.1.8"
  }
}

Run the app in development mode before packaging anything:

bash
npm start

Output:

The demo app running with npm start
Electron Demo

Hello from Electron

App version 1.0.0

win32 / x64 • Electron 33.2.0 • Node 20.18.0 • Chrome 130.0.6723.44

The Icons:

electron-builder will happily build without custom icons, but you will get the default Electron logo. Drop three files into build/ and it picks them up automatically:

FilePlatformRequirements
build/icon.icoWindowsMulti-size ICO, at least 256×256
build/icon.icnsmacOSICNS, from a 1024×1024 source
build/icon.pngLinuxPNG, 512×512 or 1024×1024

If you only have a PNG, the easiest path is to keep a 1024×1024 build/icon.png; electron-builder generates the missing formats for Linux and Windows from it. For macOS, providing the .icns yourself is the safer option.

Building for Windows:

Run this on a Windows machine:

bash
npm run dist:win

You end up with two artifacts inside dist/:

  • Electron Demo Setup 1.0.0.exe — an NSIS installer with a wizard, Start Menu entry and uninstaller.
  • Electron Demo 1.0.0.exe — the portable build, a single executable that runs with no installation.
bash
  • electron-builder  version=25.1.8
  • loaded configuration  file=package.json ("build" field)
  • packaging       platform=win32 arch=x64 electron=33.2.0
  • building        target=nsis file=dist\Electron Demo Setup 1.0.0.exe
  • building        target=portable file=dist\Electron Demo 1.0.0.exe

Want a 32-bit build too, or an MSI? Pass the architecture and target on the command line:

bash
npx electron-builder --win nsis --x64 --ia32
npx electron-builder --win msi

Building for macOS:

Run this on a Mac:

bash
npm run dist:mac

Because the mac block above lists both architectures, you get Intel and Apple Silicon versions:

  • Electron Demo-1.0.0.dmg — Intel (x64) disk image.
  • Electron Demo-1.0.0-arm64.dmg — Apple Silicon disk image.
  • .zip versions of both, which is the format auto-update uses on macOS.

You can also produce a single binary that runs natively on both chips:

bash
npx electron-builder --mac --universal
Heads-up on macOSAn unsigned app will be blocked by Gatekeeper on somebody else's Mac. For personal use that is fine — right-click the app and chooseOpen. To distribute it, you need an Apple Developer account, a Developer ID certificate, and notarization.

With a certificate installed and an app-specific password created, signing and notarizing is a matter of environment variables plus one flag:

bash
export APPLE_ID="you@example.com"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="ABCDE12345"

npm run dist:mac
json
"mac": {
  "notarize": true,
  "hardenedRuntime": true,
  "gatekeeperAssess": false,
  "entitlements": "build/entitlements.mac.plist"
}

Building for Linux:

Run this on a Linux machine (or inside WSL):

bash
npm run dist:linux

The result:

  • Electron Demo-1.0.0.AppImage — runs on virtually any distribution, no installation needed.
  • electron-demo_1.0.0_amd64.deb — package for Debian, Ubuntu and derivatives.

Make the AppImage executable and launch it:

bash
chmod +x "dist/Electron Demo-1.0.0.AppImage"
"./dist/Electron Demo-1.0.0.AppImage"

Other formats are just one more entry in the target list — rpm for Fedora and RHEL, snap for the Snap Store, tar.gz for a plain archive:

bash
npx electron-builder --linux AppImage deb rpm tar.gz

Cross-Compiling:

The command npm run dist:all (electron-builder -mwl) is real, but it only works from the right host. This is the part of the process worth memorizing:

You are onWindows targetmacOS targetLinux target
macOSYes (needs Wine)YesYes
LinuxYes (needs Wine)NoYes
WindowsYesNoPartially — use WSL or Docker

The short version: macOS builds require a Mac, because Apple's code-signing and DMG tooling does not exist anywhere else. Everything else can be arranged. On Linux you can build Windows and Linux artifacts inside the official Docker image without installing Wine yourself:

bash
docker run --rm -ti \
  -v ${PWD}:/project \
  -v ${PWD##*/}-node-modules:/project/node_modules \
  electronuserland/builder:wine \
  /bin/bash -c "npm install && npx electron-builder --win --linux"

Building All Three at Once:

The clean answer to the Mac problem is to let a build server do it. This GitHub Actions workflow runs the same build on three runners in parallel and uploads every artifact:

yaml
# .github/workflows/build.yml
name: Build desktop app

on:
  push:
    tags: ['v*']
  workflow_dispatch:

jobs:
  build:
    strategy:
      matrix:
        include:
          - os: windows-latest
            script: dist:win
          - os: macos-latest
            script: dist:mac
          - os: ubuntu-latest
            script: dist:linux

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - run: npm run ${{ matrix.script }}

      - uses: actions/upload-artifact@v4
        with:
          name: build-${{ matrix.os }}
          path: dist/*.*

Push a tag such as v1.0.0 and a few minutes later you have installers for the three platforms sitting in the workflow's artifacts, all built from the same commit.

The dist Folder:

dist/ ├─ Electron Demo Setup 1.0.0.exe <- Windows installer ├─ Electron Demo 1.0.0.exe <- Windows portable ├─ Electron Demo-1.0.0.dmg <- macOS Intel ├─ Electron Demo-1.0.0-arm64.dmg <- macOS Apple Silicon ├─ Electron Demo-1.0.0.AppImage <- Linux universal ├─ electron-demo_1.0.0_amd64.deb <- Debian / Ubuntu ├─ latest.yml / latest-mac.yml <- auto-update metadata └─ win-unpacked/ mac/ linux-unpacked/ <- raw application folders
Expect a big downloadEvery Electron app ships its own copy of Chromium, so even this demo lands somewhere around 70–100 MB per installer. That is normal, and there is no way around it — it is the price of the framework.

Common Problems:

  • A blank white window. Almost always a wrong path in win.loadFile(). Always build the path with path.join(__dirname, ...), never a relative string.
  • Works with npm start, fails when packaged. A file is missing from the files array in the build config. Anything the app loads at runtime has to be listed there.
  • window.api is undefined. The preload path is wrong, or contextIsolation was turned off.
  • “Cannot find module” after packaging. The package was installed with --save-dev but the app needs it at runtime. Move it to dependencies.
  • The build hangs on the first run. electron-builder is downloading the Electron binaries and the NSIS/Wine helpers into its cache. Let it finish; later builds are much faster.

Files:

The complete demo — main.js, preload.js, the renderer folder and the full package.json — is available in the repository:

https://gitlab.com/luisalbertosierraalcantara/electron-demo

Post a Comment

Previous Post Next Post