A Comprehensive Guide to Building and Packaging an Electron App

I recently went through the gauntlet that is trying to build and package an Electron app. I say gauntlet as the entire process of figuring out all the different ways there are to package an app for distribution was quite exhausting and a bit frustrating.

The main problem I had was that there are lots of different tools and ways to package an app. There's electron-forge, election-packager, and election-builder. Each one claims to be either a "complete tool" or "complete solution" to create, package, and distribute an Electron app. Each does a slightly different thing and figuring out which one to use to do what I wanted was difficult.

This guide aims to help you navigate these different tools and how to use them so you can choose the right one for your needs. It will also try to help you avoid the pitfalls I stumbled into while learning them.

What this guide does not cover are the various ways to integrate Electron into different frameworks, for example Wepback. This guide will only cover Electron itself and its various tools.

So without further ado, let's dive in.

Table of Contents

Building An Electron App

Before you can package an Electron app, you first must build one. I found building an app to be the easiest part of the whole process. The Electron Quick Start Guide was indeed quick and I was able to get my app booted in Electron following their guide without any problems.

However, problems arose as I started trying to integrated Electron APIs into my app. Below are some common problems I ran into and their solutions.

Communicating with the Main Process

As far as I could tell, the Electron docs do not cover communicating between the app and the main process.

The most common way to do that is to use the ipcRenderer to send and receive messages. For example, if you wanted to send a command to tell the app to quit, you would do the following:

// inside the main process (e.g. main.js)
const { app, BrowserWindow, ipcMain } = require('electron');

function createWindow () {
  const win = new BrowserWindow({
    webPreferences: {
      nodeIntegration: true
    }
  });

  win.loadFile('index.html')
}

app.whenReady().then(createWindow);

ipcMain.on('quit-app', () => app.quit());
// inside the app (e.g. index.html)
const { ipcRenderer } = require('electron');
ipcRenderer.send('quit-app');

Notice that you use the ipcRenderer object in the app to send the message but the ipcMain object to listen for it. Also for this to work you must enable webPreferences.nodeIntegration when you create the browser window, otherwise you cannot use require inside of the app.

However, enabling nodeIntegration is actually a security risk. Allowing your app to not only be able to require arbitrary code but also allow that code to communicate and send arbitrary commands to your main process is dangerous.

Secure Communication

There is a secure way to handle communicating with your main process. It's buried deep in an Electron issue not even related to a security problem so it can be very easy to miss.

The gist of the solution is to limit how much access your app has to the ipcRenderer so it can only send and receive very specific commands all while disabling nodeIntegration and other attack vectors. All credit to reZach for the following solution.

// inside the main process (e.g. main.js)
const { app, BrowserWindow, ipcMain } = require('electron');

function createWindow () {
  const win = new BrowserWindow({
    webPreferences: {
      nodeIntegration: false, // is default value after Electron v5
      contextIsolation: true, // protect against prototype pollution
      enableRemoteModule: false, // turn off remote
      preload: path.join(__dirname, 'preload.js') // use a preload script
    }
  });

  win.loadFile('index.html')
}

app.whenReady().then(createWindow);

ipcMain.on('toMain', (event, command) => {
  // process specific commands
});
// preload.js
const { contextBridge, ipcRenderer } = require('electron');

// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld(
  'api', {
    send: (channel, data) => {
      // allow only specific channels
      const validChannels = ['toMain'];
      if (validChannels.includes(channel)) {
        ipcRenderer.send(channel, data);
      }
    },
    receive: (channel, func) => {
      const validChannels = ['fromMain'];
      if (validChannels.includes(channel)) {
        // Deliberately strip event as it includes `sender` 
        ipcRenderer.on(channel, (event, ...args) => func(...args));
      }
    }
  }
);
// inside the app (e.g. index.html)
window.api.send('quit-app');

If you'd like to do even more to secure your app, reZach created a secure-electron-template repo that you can take from.

Opening DevTools on Windows

While developing and testing your Electron app, it's handy to be able to open the DevTools and inspect the current state or just add breakpoints for debugging.

The easiest way to open the DevTools is to start the app with them opened using webContents.openDevTools(). The function also takes an options argument that lets you define how to open them.

// inside the main process (e.g. main.js)
const { app, BrowserWindow } = require('electron');

function createWindow () {
  const win = new BrowserWindow();
  win.webContents.openDevTools();
}

app.whenReady().then(createWindow);

Unfortunately, although this works fine on Mac, it does not work for Windows.

Luckily, there's an issue open in Electron which has a work around for the problem. The solution is to use webContents.setDevToolsWebContents() and a second browser window set to open the DevTools. All credit to warrenseine for the following solution.

// inside the main process (e.g. main.js)
const { app, BrowserWindow } = require('electron');

function createWindow () {
  const win = new BrowserWindow();
  const devtools = new BrowserWindow();

  win.webContents.setDevToolsWebContents(devtools.webContents);
  win.webContents.openDevTools({ mode: 'detach' });
}

app.whenReady().then(createWindow);

Building on WSL

Most of the time I develop using Mac, but since I was building an Electron app I wanted to test if my app still worked on Windows when built. Using the Windows Subsystem for Linux (WSL) I tried to run the app using npm start.

Unfortunately I couldn't even start the app. Every time I would run npm run start I would get an error stating that a missing shared library couldn't be located.

Error while loading shared libraries: libxshmfence.so.1: cannot open shared object file: No such file or directory

For the longest time I couldn't figure out what to do to solve this. Then I discovered the answer buried in the electron-forge docs (of all places). It turns out you need to reinstall node_modules and tell npm to use win32 platform prebuilt binaries.

rm -r node_modules
npm install --platform=win32

Once installed, you can now run npm run start and the Electron app should start.

However once started the app wasn't running. Looking at the Network tab it seems all requests but for the first few were Pending indefinitely. I wasn't sure how to proceed after that and couldn't find any information online. I even tried upgrading to use WSL2 (as I was still on 1) but that didn't fix the problem.

The only answer I was able to find was to instead install node and electron on Windows itself and then start the electron app from WSL. Not the best solution, but it does seem to work.

Other Problems

These were the problems I ran into, but for more problems and their solutions read 4 must-know tips for building cross platform Electron apps.

Packaging an Electron App

As mentioned before, there are 3 different tools to package an Electron app: electron-forge, electron-packager, and electron-builder. As we look at each one we'll be trying to package an app for Mac, Windows, and Linux.

The Electron Quick Start Guide recommends installing electron-forge to handle packaging so we'll start with that one.

Prerequisites for Packaging Windows Apps on Mac

If you plan to package your application for Windows on a Mac, you'll first need to install Wine. Wine is program that lets non-Windows operating systems run Windows software. Electron packaging tools will use Windows APIs to build the Windows package.

To install Wine on Mac, follow the How to install/about wine on mac guide.

electron-forge

First install electron-forge into your project and run the import command.

npm install --save-dev @electron-forge/cli
npx electron-forge import

The import command will configure your app to use electron-forge and add a few package scripts and a config property to your package.json.

"scripts": {
  "start": "electron-forge start",
  "package": "electron-forge package",
  "make": "electron-forge make"
},
"config": {
  "forge": {
    "packagerConfig": {},
    "makers": [
      {
        "name": "@electron-forge/maker-squirrel",
        "config": {
          "name": "<appname>"
        }
      },
      {
        "name": "@electron-forge/maker-zip",
        "platforms": [
          "darwin"
        ]
      },
      {
        "name": "@electron-forge/maker-deb",
        "config": {}
      },
      {
        "name": "@electron-forge/maker-rpm",
        "config": {}
      }
    ]
  }
}

Important Note: The scripts electron-forge creates will override any scripts of the same name that are already defined. So if you have a start, package, or make script already then they will be overridden by electron-forge.

Once set up, you can run npm run make to build a Mac package and a zip file containing the Mac package of your app (if on Mac). However, it will not build a Windows or Linux package.

At this point I wasn't sure how to build a Windows or Linux package using electron-forge. Even though the default config provided by the import command sets up a Windows maker using maker-squirrel, it doesn't produce one. Nor does it produce a Linux package using maker-deb.

I thought maybe the packagerConfig needed to be told to build them. Trying to set "all": true so that electron-packager would build for all platforms didn't work as I got an error from electron-forge saying it wasn't supported.

Error: config.forge.packagerConfig.all is not supported by Electron Forge

I next tried to set platforms to ['darwin', 'linux', 'win32'] on the packagerConfig object but that didn't do anything as only the Mac application was built again.

Lastly, I noticed that you could pass the make cli command options to specify the platform. I tried passing win32 and this somewhat worked as it tried to build a squirrel package. Unfortunately it also produced a very unhelpful error about not being able to spawn mono.

npm run make -- --platform win32
...
Making for target: squirrel - On platform: win32 - For arch: x64

An unhandled error has occurred inside Forge:
An error occured while making for target: squirrel
spawn mono ENOENT
Error: spawn mono ENOENT

It would seem that electron-forge cannot package for other platforms (their docs even say as much). As such, my opinion is that using electron-forge is not a solution for cross-platform packaging.

Target platform to make for, please note you normally can only target platform X from platform X

electron-packager

The next tool is election-packager. Electron-packager is used by electron-forge to build cross platform packages that the make script then uses to build a package.

First install electron-packager.

npm install --save-dev electron-packager

Once installed, you can build for all platforms using the --all flag when running the electron-packager cli. You'll also need to make sure to provide icons for each platform package to use. Each platform uses a different extension for the icon: .icns for Mac, .ico for Windows, and .png for Linux.

If you have all three icons in a single directory, you can tell electron-packager to use the directory and it will automatically choose the correct icon to use for the package.

npx electron-packager . <appname> --all --icon=path/to/icons/dir

Once run, you should have built packages for each of the three platforms and for each different architecture (a total of eleven packages).

If you don't want all platforms and architectures, you can instead provide the --platform and --arch flags. So if you just wanted Mac x64, and Windows and Linux 32-bit and 64-bit architectures you would do the following:

npx electron-packager . <appname> --platform=darwin,linux,win32 --arch=ia32,x64 --icon=path/to/icons/dir

This will produce a warning about Mac and ia32 not being supported, but it will not stop the build from producing the other combinations.

One thing to note about these packages is that they are what's known as "unpackaged" packages. What that means is that the Windows and Unix executables are not standalone executables. Running them requires additional files that are included in each of their directories.

To distribute these unpackaged packages you'll need to provide your users with the executable and all the other files. For the most part, this isn't an ideal way to distribute an app, but can work in some cases (for example, uploading a game to Steam).

If instead you want to distribute a single standalone executable you'll need to use electron-builder.

electron-builder

Election-builder works in a similar way to electron-forge in that you provide a package.json config object which tells electron-builder which packages to build.

First install electron-builder.

npm install electron-builder

Unlike electron-forge, you'll have to create the build config and scripts manually instead of it being generated for you. The electron-builder Quick Setup Guide provides a sample build config and recommend scripts to setup. It also recommends adding a postinstall script to ensure electron-builder is always up-to-date with your native environment.

"build": {
  "appId": "your.id",
  "mac": {
    "category": "your.app.category.type"
  }
},
"scripts": {
  "pack": "electron-builder --dir",
  "dist": "electron-builder"
},
"postinstall": "electron-builder install-app-deps"

The pack script will build unpackaged versions of your app, just as electron-packager does. The dist script will build the distribution version of your app, which results in a single standalone executable.

In order to build for multiple platforms you'll need to expand the build config to include how to build for your platforms of choice, as well as where to find the icons. The icon option can also take a path to the icon directory where it will automatically select the correct extension to use for the platform.

The electron-builder docs do a good job explaining the options for Mac targets, Windows targets, and Linux targets.

A minimalistic build config could look like the following.

"build": {
  "appId": "your.id",
  "icon": "path/to/icons/dir",
  "mac": {
    "category": "your.app.category.type"
  },
  "win": {
    "target": "portable"
  },
  "linux": {
    "target": "AppImage"
  }
}

Afterwards, you can run npm run dist and it should run. If you haven't set up code signing for your Mac app, the build will let you know but it will package the app without it just fine.

However, just like electron-forge, the dist script only produced a Mac executable (if on Mac). No amount of adjusting the build config would produce a Windows or Linux package.

It turns out that by default the electron-builder command will only build for your current platform. The solution is to provide additional flags to tell it to produce for other platforms. In this case, you want to pass the Mac, Windows, and Linux flags -m, -w, -l (respectively). You can also combine the flags into a single flag -mwl (order doesn't appear to matter).

electron-builder -mwl

With that, electron-builder will produce three packaged executables for Mac, Windows, and Linux. These are standalone packages so each of the executables will be able to run by just opening it (no other files needed).

Which Packaging Tool to Use

Now that we've discussed each of the different tools, which one should you use?

Electron-forge only packages for your current platform, so it doesn't really work for packaging cross platform apps unless you own each of the platforms and don't mind packaging on each of them separately.

Electron-packager does package for each platform, but it only produces unpackaged applications. This means you'll need to provide your users with all the files in each package directory in order for the executables to work.

Electron-builder is my recommendation as it packages for each platform and produces packaged applications. It also can produce unpackaged applications so there's no need to use electron-packager.

Packaging on WSL

Although I didn't run into any problems packaging the app on WSL, I did run into a few problems when trying to run the packaged app.

No Audio in Electron App

First, if you're using WSL1 to package the app, some features may not work.

In my case, my app was using the Web Audio API to play music. However when I booted the app no sound would play. I tried to play audio by instead using an Audio element, but that also didn't work. Using the canPlayType() function for my audio type resulted in "Probably" so that wasn't the cause.

As with the last WSL problem, I couldn't find anything online. However, I did discover that the Windows executable package produced on my Mac would play the audio just fine. I concluded that maybe WSL didn't have any audio drivers and thus electron couldn't package the app with an audio output device enabled.

I was able to solve the problem though by upgrading to WSL2. If you run into a similar problem I would suggest starting there.

Mac Package Didn't Work

When I packaged the Mac application I wanted to see if it would actually work on a Mac. Unfortunately the Mac zip archive that electron-builder produces didn't work. Trying to open the .app file after unzipping it result in a "You do not have permission to open the application" message.

I tried following the solution from this StackExchange issue, but changing the permissions of all files in the app and removing the quarantine bits didn't help. Instead, after I did that and tried to open the app I got a new problem saying that the app couldn't open due to a problem.

Dyld Error Message:
  dyld: Using shared cache: BD3DF5DB-AF3C-3D09-B8D6-4352F71DDBEA
Library not loaded: @rpath/Electron Framework.framework/Electron Framework
  Referenced from: /Users/USER/Downloads/myApp.app/Contents/MacOS/myApp
  Reason: no suitable image found.  Did find:
  /Downloads/myApp.app/Contents/MacOS/../Frameworks/Electron Framework.framework/Electron Framework: file too short
  /Downloads/myApp.app/Contents/MacOS/../Frameworks/Electron Framework.framework/Electron Framework: stat() failed with errno=1

Digging further I found that someone suggested archiving with 7zip rather than zip. Changing the Mac target to 7z produced a 7zip archive which after unzipping was able to open without the previous errors.

"build": {
  "mac": {
    "target": "7z"
  }
}

Of course since the app is unsigned Apple Gatekeeper will prevent you from opening it as it's from an unidentified developer. Code signing doesn't work on non-Mac platforms so the only way to open it is to right click the app and click "Open" from the context menu.

Doing this should allow you to open the app without further issues.