Building and publishing an Electron application using electron-builder

Farhan CK

Farhan CK

October 22, 2024

Building and publishing an Electron application using electron-builder

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 processes

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.

Project structure

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

Browser window preload script

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.

Build

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.

Package

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.

Publish

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.

Auto-update

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.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.