Recently, we built NeetoRecord, a loom alternative. The desktop application was built using Electron. In a series of blogs, we capture how we built the desktop application and the challenges we ran into. This blog is part 2 of the blog series. You can also read part 1, part 3, part 4, part 5, part 6, part 7 part 8 and part 9 .
Building, packaging and publishing an app with the default Electron npm packages can be quite challenging. It involves multiple packages and offers limited customization. Additionally, setting up auto-updates requires significant additional effort, often involving separate tools or services.
electron-builder is a complete solution for building, packaging and distributing Electron applications for macOS, Windows and Linux. It is a highly configurable alternative to the default Electron packaging process and supports auto-update out of the box.
In this blog, we look into how we can build, package and distribute Electron
applications using electron-builder
.
Electron has two types of processes: the main
process and the renderer
process. The main process acts as the entry point to the application, where we
can create a browser window and load a webpage. This webpage runs in the
renderer
process. The main
process is written in Node.js
, while the
renderer process can be developed using JavaScript or any JS framework like
React
, Vue
, or Angular
.
When building an Electron application, it's best to keep the main
and
renderer
processes in separate folders since they are built separately.
electron-app
├── assets
├── release
├── src
│ └── main
│ └── main.js
│ └── renderer
│ └── App.jsx
│ └── index.ejs
├── node_modules
├── package.json
Since the main
process is written in Node.js
, it has access to Node.js
and
Electron APIs, but the renderer
process does not. To bridge this gap, Electron
supports a special script called a preload
script, which we can specify when
creating a BrowserWindow
. This script runs in a context that has access to
both the HTML DOM and a limited subset of Node.js and Electron APIs. An example
preload
script looks like this:
import { contextBridge, ipcRenderer } from "electron";
const electronHandler = {
ipcRenderer: {
sendMessage(channel, ...args) {
ipcRenderer.send(channel, ...args);
},
on(channel, func) {
const subscription = (_event, ...args) => func(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
once(channel, func) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
},
};
contextBridge.exposeInMainWorld("electron", electronHandler);
This preload script exposes send
, on
and once
methods of ipcRenderer
to
the renderer process via the contextBridge
. It allows the renderer to send
messages, listen for events, and handle one-time events from the main process
while maintaining security by controlling which APIs are accessible.
Since an Electron application has two processes, we need two separate Webpack
configurations—one for the main
process and another for the renderer
process.
// ./config/webpack/main.mjs
import path from "path";
import webpack from "webpack";
import { merge } from "webpack-merge";
import TerserPlugin from "terser-webpack-plugin";
const configuration = {
target: "electron-main",
entry: {
main: "src/main/main.mjs",
preload: "src/main/preload.mjs",
},
output: {
path: "release/app/dist/main",
filename: "[name].js",
library: {
type: "umd",
},
},
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
node: {
__dirname: false,
__filename: false,
},
};
export default configuration;
The above is a basic Webpack configuration for the main
process. Webpack
supports Electron out of the box, so by setting target: "electron-main"
,
Webpack includes all the necessary Electron variables. Since we also have a
preload
script, we added preload.mjs
as an entry point as well. We will be
minifying the code using TerserPlugin
.
Another important detail is that we've disabled __dirname
and __filename
.
This prevents Webpack's handling of these variables from interfering with
Node.js's native __dirname
and __filename
, ensuring they behave as expected
in our Electron app.
// ./config/webpack/renderer.mjs
import path from "path";
import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
import CssMinimizerPlugin from "css-minimizer-webpack-plugin";
import { merge } from "webpack-merge";
import TerserPlugin from "terser-webpack-plugin";
const configuration = {
target: ["web", "electron-renderer"],
entry: "src/renderer/App.jsx",
output: {
path: "release/app/dist/renderer",
publicPath: "./",
filename: "renderer.js",
library: {
type: "umd",
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
"sass-loader",
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"sass-loader",
"postcss-loader",
],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: "asset/resource",
},
// Images
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: "asset/resource",
},
// SVG
{
test: /\.svg$/,
use: [
{
loader: "@svgr/webpack",
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
"file-loader",
],
},
],
},
optimization: {
minimize: true,
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
},
plugins: [
new MiniCssExtractPlugin({
filename: "style.css",
}),
new HtmlWebpackPlugin({
filename: "index.html",
template: "src/renderer/index.ejs",
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: false,
}),
],
};
export default configuration;
In the renderer
configuration, we set the target to
target: ["web", "electron-renderer"]
, which provides both standard web and
Electron's renderer variables. Similar to a typical web application setup, we
load various plugins to handle fonts, images, and SVGs and to minify CSS and
JavaScript. Since JavaScript files are loaded locally in an Electron
application, we can bundle everything into a single file called renderer.js
instead of splitting it into multiple chunks as we would for a standard web
application.
Our build configuration is ready; add it to scripts
in package.json
for easy
execution.
"scripts": {
"build:main": "cross-env NODE_ENV=production webpack --config ./config/webpack/main.mjs",
"build:renderer": "cross-env NODE_ENV=production webpack --config ./config/webpack/renderer.mjs",
"build": "yarn build:main && yarn build:renderer",
}
Great! Now, we can build the entire Electron application using a single
yarn build
command.
We can configure the electron-builder
in package.json
using the build
field. Below is an example configuration for an app named MyApp
.
"build": {
"productName": "MyApp",
"appId": "com.neeto.MyApp",
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"mac": {
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
]
}
},
"win": {
"target": {
"target": "nsis",
"arch": [
"x64"
]
},
"artifactName": "${productName}-Setup-${version}.${ext}"
},
"linux": {
"category": "Utility",
"target": [
{
"target": "rpm",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
],
},
}
In the Webpack configuration, we specified release/app
as the output directory
for the compiled JS code. This directory needs to be specified in
electron-builder
so it knows where to find the compiled code during packaging.
Use the directories.app
field to specify this path, and we can also define
where the packaged builds should be output using the directories.output
field.
In addition to directories
, the configuration includes settings for appId
,
productName
and platform-specific configurations for mac
, win
(Windows),
and linux
. For each platform, we specify the installer target and
architecture. This configuration will produce builds for both Intel (x64
) and
Apple Silicon (arm64
) on macOS. For Windows, it generates an NSIS
installer targeting 64-bit architecture (x64
). On Linux, it produces both
RPM
and DEB
installers for 64-bit architecture (x64
).
With the electron-builder
configuration set, we can proceed to package our
application using the electron-builder build
command.
"scripts": {
"build:main": "cross-env NODE_ENV=production webpack --config ./webpack/main.prod.mjs",
"build:renderer": "cross-env NODE_ENV=production webpack --config ./webpack/renderer.prod.mjs",
"build": "yarn build:main && yarn build:renderer",
"package": "yarn build && electron-builder build",
}
We run yarn build
before electron-builder build
to ensure that the
JavaScript code is compiled before packaging. This enables us to handle both the
build and packaging processes in a single command.
To publish the app to a server where users can download and use it, we can pass
the --publish
flag to electron-builder build
command. Before we can do that,
we need to update our electron-builder
configuration with publish
server
information.
Here is an example configuration to publish the builds to an S3 bucket named
my-app-downloads
:
"build": {
// other configs
"publish": [
{
"provider": "s3",
"bucket": "my-app-downloads",
"path": "/electron/my-app/",
"region": "us-east-1",
"acl": null
},
]
}
The publish
field accepts an array, allowing us to publish to multiple
locations. We need to specify a provider, such as s3
, but electron-builder
also supports other providers like github
and more by default. For a complete
list of all publishing options, check out the
publish documentation.
To wrap things up, let's automate the deployment process by creating a GitHub Actions workflow. Since building macOS apps is only supported on macOS, we'll need to run the workflow from a macOS environment.
name: Publish
jobs:
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
steps:
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: "adopt"
java-version: "11"
- name: Checkout git repo
uses: actions/checkout@v3
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 20
cache: npm
- name: Install rpm using Homebrew
run: brew install rpm
- name: Install and build
run: |
yarn install
yarn build
- name: Publish releases
env:
AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY}}
AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET}}
run: npm exec electron-builder -- --publish always -mwl
To successfully build for Windows and Linux from macOS, we'll need to set up a
Java version and install the rpm
package. Additionally, configure our
AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
for the S3 bucket where we plan
to upload the builds. Once these steps are complete, we should be able to build
and publish our Electron app using the electron-builder -- --publish
command.
The -mwl
flag indicates that the build should target macOS, Windows, and
Linux.
To enable auto-updating for our application, we first need to code-sign and notarize it. We'll cover the code-signing and notarization details in upcoming blog posts. Stay tuned!
If this blog was helpful, check out our full blog archive.