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 and part 4, part 5.
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.
1electron-app 2├── assets 3├── release 4├── src 5│ └── main 6│ └── main.js 7│ └── renderer 8│ └── App.jsx 9│ └── index.ejs 10├── node_modules 11├── 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:
1import { contextBridge, ipcRenderer } from "electron"; 2 3const electronHandler = { 4 ipcRenderer: { 5 sendMessage(channel, ...args) { 6 ipcRenderer.send(channel, ...args); 7 }, 8 on(channel, func) { 9 const subscription = (_event, ...args) => func(...args); 10 ipcRenderer.on(channel, subscription); 11 12 return () => { 13 ipcRenderer.removeListener(channel, subscription); 14 }; 15 }, 16 once(channel, func) { 17 ipcRenderer.once(channel, (_event, ...args) => func(...args)); 18 }, 19 }, 20}; 21 22contextBridge.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.
1// ./config/webpack/main.mjs 2 3import path from "path"; 4import webpack from "webpack"; 5import { merge } from "webpack-merge"; 6import TerserPlugin from "terser-webpack-plugin"; 7 8const configuration = { 9 target: "electron-main", 10 entry: { 11 main: "src/main/main.mjs", 12 preload: "src/main/preload.mjs", 13 }, 14 output: { 15 path: "release/app/dist/main", 16 filename: "[name].js", 17 library: { 18 type: "umd", 19 }, 20 }, 21 optimization: { 22 minimizer: [ 23 new TerserPlugin({ 24 parallel: true, 25 }), 26 ], 27 }, 28 node: { 29 __dirname: false, 30 __filename: false, 31 }, 32}; 33 34export 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.
1// ./config/webpack/renderer.mjs 2 3import path from "path"; 4import webpack from "webpack"; 5import HtmlWebpackPlugin from "html-webpack-plugin"; 6import MiniCssExtractPlugin from "mini-css-extract-plugin"; 7import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; 8import CssMinimizerPlugin from "css-minimizer-webpack-plugin"; 9import { merge } from "webpack-merge"; 10import TerserPlugin from "terser-webpack-plugin"; 11 12const configuration = { 13 target: ["web", "electron-renderer"], 14 entry: "src/renderer/App.jsx", 15 output: { 16 path: "release/app/dist/renderer", 17 publicPath: "./", 18 filename: "renderer.js", 19 library: { 20 type: "umd", 21 }, 22 }, 23 module: { 24 rules: [ 25 { 26 test: /\.s?(a|c)ss$/, 27 use: [ 28 MiniCssExtractPlugin.loader, 29 { 30 loader: "css-loader", 31 options: { 32 modules: true, 33 sourceMap: true, 34 importLoaders: 1, 35 }, 36 }, 37 "sass-loader", 38 ], 39 include: /\.module\.s?(c|a)ss$/, 40 }, 41 { 42 test: /\.s?(a|c)ss$/, 43 use: [ 44 MiniCssExtractPlugin.loader, 45 "css-loader", 46 "sass-loader", 47 "postcss-loader", 48 ], 49 exclude: /\.module\.s?(c|a)ss$/, 50 }, 51 // Fonts 52 { 53 test: /\.(woff|woff2|eot|ttf|otf)$/i, 54 type: "asset/resource", 55 }, 56 // Images 57 { 58 test: /\.(png|jpg|jpeg|gif)$/i, 59 type: "asset/resource", 60 }, 61 // SVG 62 { 63 test: /\.svg$/, 64 use: [ 65 { 66 loader: "@svgr/webpack", 67 options: { 68 prettier: false, 69 svgo: false, 70 svgoConfig: { 71 plugins: [{ removeViewBox: false }], 72 }, 73 titleProp: true, 74 ref: true, 75 }, 76 }, 77 "file-loader", 78 ], 79 }, 80 ], 81 }, 82 83 optimization: { 84 minimize: true, 85 minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], 86 }, 87 88 plugins: [ 89 new MiniCssExtractPlugin({ 90 filename: "style.css", 91 }), 92 new HtmlWebpackPlugin({ 93 filename: "index.html", 94 template: "src/renderer/index.ejs", 95 minify: { 96 collapseWhitespace: true, 97 removeAttributeQuotes: true, 98 removeComments: true, 99 }, 100 isBrowser: false, 101 isDevelopment: false, 102 }), 103 ], 104}; 105 106export 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.
1"scripts": { 2 "build:main": "cross-env NODE_ENV=production webpack --config ./config/webpack/main.mjs", 3 "build:renderer": "cross-env NODE_ENV=production webpack --config ./config/webpack/renderer.mjs", 4 "build": "yarn build:main && yarn build:renderer", 5}
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.
1 "build": { 2 "productName": "MyApp", 3 "appId": "com.neeto.MyApp", 4 "directories": { 5 "app": "release/app", 6 "buildResources": "assets", 7 "output": "release/build" 8 }, 9 "mac": { 10 "target": { 11 "target": "default", 12 "arch": [ 13 "arm64", 14 "x64" 15 ] 16 } 17 }, 18 "win": { 19 "target": { 20 "target": "nsis", 21 "arch": [ 22 "x64" 23 ] 24 }, 25 "artifactName": "${productName}-Setup-${version}.${ext}" 26 }, 27 "linux": { 28 "category": "Utility", 29 "target": [ 30 { 31 "target": "rpm", 32 "arch": [ 33 "x64" 34 ] 35 }, 36 { 37 "target": "deb", 38 "arch": [ 39 "x64" 40 ] 41 } 42 ], 43 }, 44 }
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.
1"scripts": { 2 "build:main": "cross-env NODE_ENV=production webpack --config ./webpack/main.prod.mjs", 3 "build:renderer": "cross-env NODE_ENV=production webpack --config ./webpack/renderer.prod.mjs", 4 "build": "yarn build:main && yarn build:renderer", 5 "package": "yarn build && electron-builder build", 6}
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:
1 "build": { 2 // other configs 3 "publish": [ 4 { 5 "provider": "s3", 6 "bucket": "my-app-downloads", 7 "path": "/electron/my-app/", 8 "region": "us-east-1", 9 "acl": null 10 }, 11 ] 12 }
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.
1name: Publish 2 3jobs: 4 publish: 5 runs-on: ${{ matrix.os }} 6 7 strategy: 8 matrix: 9 os: [macos-latest] 10 11 steps: 12 - name: Setup Java 13 uses: actions/setup-java@v3 14 with: 15 distribution: "adopt" 16 java-version: "11" 17 18 - name: Checkout git repo 19 uses: actions/checkout@v3 20 21 - name: Install Node and NPM 22 uses: actions/setup-node@v3 23 with: 24 node-version: 20 25 cache: npm 26 27 - name: Install rpm using Homebrew 28 run: brew install rpm 29 30 - name: Install and build 31 run: | 32 yarn install 33 yarn build 34 35 - name: Publish releases 36 env: 37 AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY}} 38 AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET}} 39 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!