← Back to the listMay 2, 202011 mins read — electron, react, js

Electron Tips

This post might be a bit longer than my usual TIL entries, but I’ve been working on some side projects using Electron lately, so I thought I’d keep track of some of the learnings I had on the way 😊

0. Start with the docs

Before going into any details, I just want to point out that the Electron docs, as well as the docs of libraries around it in its ecosystem are excellent. Imo they should always be the starting point whenever you don’t know how to do something 🤓

1. Development setup (incl. React for rendering)

There are plenty of boilerplates out there, all serving different use cases, different tech stacks, focusing on different requirements, etc. So my general tip would be: have a look around and pick whatever matches your needs the best to start with, and then evolve it from there.

My setup was focused around using React for the rendering process. In general, I found the following structure a good starting point:

.
├── dist # automatically generated by electron-builder
├── js # react app, e.g. using CRA
│   ├── src
│   │   ├── components
│   │   ├── node # all scripts that take advantage of the node env
│   │   ├── screens
│   │   └── ...
│   └── package.json
├── support
│   └── ... # any scripts that help with chores, e.g. icon generation
├── main.js # electron entry point
└── package.json

This pretty much follows the setups I’m used to from web app development with React. I used CRA for the React side of things, which is great because you don’t have to deal with build and bundling setups, but it also meas you can’t deal with build and bundling setups.

One of the issues I ran into were around the babel setup, mainly cause I like to use require and module.exports in my node scripts, but import and export in my react scripts. To be able to do so, I needed to override the babel sourceType to unambiguous. To avoid having to eject the project, I used rescripts to adjust the config 🙈

// .rescripsrc.js
const fixConfig = config => {
  // HACK: the wrong sourceType only seems to break the app in prod
  // mode for whatever reason, this is obv super brittle but works for now...
  config.module.rules[2].oneOf[1].options.sourceType = "unambiguous";
  return config;
};

module.exports = [fixConfig];

Another issue, when bundling for production, is that CRA uses abolute paths for all assets by default. But you can change this by simply setting the PUBLIC_PATH env variable to .

{
  "scripts": {
    "build": "PUBLIC_PATH=. rescripts build"
  }
}

For a smoother development experience, I have the following in my main.js to load the React app through the dev server in dev mode, but from the build folder in prod mode:

const { app, BrowserWindow, ipcMain } = require("electron");

const isDev = process.argv[2] === "--dev";

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    //...
  });

  if (isDev) {
    mainWindow.loadURL("http://localhost:3000");
  } else {
    mainWindow.loadFile("./js/build/index.html");
  }

  // ...
}

This way I just need to add --dev to the npm scripts I want to use the dev server for, e.g.

{
  "scripts": {
    "electron:start": "electron . --dev",
    "electron:build": "electron-builder --publish always","
  }
}

2. Running node scripts from the render process

The easiest way, which I opted for at least for now, is to simply import the scripts into your render process. The only thing you need to keep in mind is that you need to use Electron’s require method whenever you want to use node-only libraries, e.g.

import React from "react"; // normal import
const { useDispatch } = require("react-redux"); // normal require
const fs = window.require("fs"); // window.require = Electron require

If you don’t, you will see error messages telling you that fs is not available 🙈

I’m sure importing all scripts like this in (assumingly) running everything in the same process has a lot of drawbacks, but for now it works fine for me. Note that all dependencies that you import via window.require need to be part of the final bundle, so they need to be added to the root package.json. But more about that later.

An alternative approach to deal with node processes could be to run them in the main process, and use the ipc event API to communicate between the main and the rendering process (via ipcMain and ipcRenderer). While this seems a lot cleaner, because it moves potentially expensive tasks into the main process and it encourages avoiding side effects through a redux like action/reducer pattern, it seemed overkill so far for the simple use cases I have.

3. Bundle app for production

You don’t need to, but Electron generally seems to encourage using dedicated libraries for the bundling like electron-builder or electron-packager. I chose electron-builder, mainly because the docs seemed good and the setup looked straight forward.

I’m currently just building for macOS, but judging from the docs electron-builder makes it fairly simple to build for any other platform it supports. The integration with electron-updater is neat as well (see below).

In general, the setup for a basic app is really simple within the package.json, while definitely providing all flexibility needed for more complex use cases:

{
  "build": {
    "appId": "com.julianburr.some-app",
    "productName": "Some app",
    "directories": {
      "buildResources": "."
    },
    "mac": {
      "icon": "logo.icns"
    },
    "publish": {
      // auto update
    }
  }
}

To generate the *.icns files for my projects, I wrote a small node script that literally just runs some commands on a given png:

// generate-icons.js
const { execSync } = require("child_process");
const path = require("path");

const root = path.resolve(__dirname, "..");

execSync(
  `rm -rf logo.iconset\n` +
    `mkdir logo.iconset\n` +
    `sips -z 16 16     ${root}/logo.png --out ${root}/logo.iconset/icon_16x16.png\n` +
    `sips -z 32 32     ${root}/logo.png --out ${root}/logo.iconset/icon_16x16@2x.png\n` +
    `sips -z 32 32     ${root}/logo.png --out ${root}/logo.iconset/icon_32x32.png\n` +
    `sips -z 64 64     ${root}/logo.png --out ${root}/logo.iconset/icon_32x32@2x.png\n` +
    `sips -z 128 128   ${root}/logo.png --out ${root}/logo.iconset/icon_128x128.png\n` +
    `sips -z 256 256   ${root}/logo.png --out ${root}/logo.iconset/icon_128x128@2x.png\n` +
    `sips -z 256 256   ${root}/logo.png --out ${root}/logo.iconset/icon_256x256.png\n` +
    `sips -z 512 512   ${root}/logo.png --out ${root}/logo.iconset/icon_256x256@2x.png\n` +
    `sips -z 512 512   ${root}/logo.png --out ${root}/logo.iconset/icon_512x512.png\n` +
    `sips -z 1024 1024 ${root}/logo.png --out ${root}/logo.iconset/icon_512x512@2x.png\n` +
    `iconutil -c icns logo.iconset\n` +
    `rm -rf logo.iconset`
);

4. Bundle size

When I first bundled one of my side projects, I was a shocked: the final dmg was ~300MB, even zipped. I knew I didn’t optimise anything yet, but still 😳

My setup was different from what I described in the beginning, with the main difference being that I had everything in the same root folder with only one package.json, cause it seemed more convenient. That however meant that all dependencies, that CRA already included in the vendor bundle, also got copied into the electron build.

I found some interesting discussions and suggestions around bundle size here:

Generally electron-builder itself suggest the so called “two package structure”, which basically splits node dependencies and renderer dependencies into two separate mini projects, both with their own independent package.json. This way, by including the root node_modules in your final bundle, you don’t include any dependencies that you don’t need.

It also helps to customise the electron-builder settings, to only include files that are really needed via the files option:

E.g.

{
  "build": {
    //...
    "files": [
      "LICENSE",
      "logo.icns",
      "main.js",
      "js/build",
      "node_modules",
      "package.json",
      "!./node_modules/surge/**"
    ]
  }
}

After splitting dependencies up and applying other optimisations the bundled application size went down to ~60MB, which is still not awesome but at least much better 😅

5. Auto updates (incl. using your own server)

As mentioned before, react-updater works nicely with react-builder. You basically just need to specify the provider and related meta information that you want to use for the auto update functionality, and the rest happens pretty much out of the box.

There are pre-configured s3 and Github providers, but I decided to upload and host the releases myself. I felt like it would give me a bit more control over the flow, and I still host the release free using surge. All you need to add for that to your package.json is:

{
  "build": {
    //...
    "publish": {
      "provider": "generic",
      "url": "https://[url of server where the release is hosted]"
    }
  }
}

Then you can add the following to your main.js:

const { autoUpdater } = require("electron-updater");

function createWindow() {
  mainWindow = new BrowserWindow({
    //...
    show: false
  });
  mainWindow.once("ready-to-show", () => {
    mainWindow.show();
    autoUpdater.checkForUpdatesAndNotify();
  });
}

autoUpdater.on("checking-for-update", () => {
  mainWindow.webContents.send("checking-for-update");
});
// ^ other events, usually also sending through some form of meta data
// - update-available
// - update-not-available
// - error
// - download-progress
// - update-downloaded

// When user chooses to manually restart the app
// to install the update
ipcMain.on("restart-app", () => {
  autoUpdater.quitAndInstall();
});

The auto updater will do the following in the background:

  • fetch latest-*.yml from the url specified in the config
  • if the version on the server is higher than the current app version, it will start downloading the release from the server
  • it will then notify you when the download is done, and install it next time the app is quit - you can use the events it triggers to show any custom banners or notfications in your app to signal that an update is available

NOTE: for auto updates to work on macOS you need to have your app code signed, so lets move to that next 😊

6. Code signing (for macOS)

First of all, you need a Mac to create a certificate to code sign your app. At least I couldn’t find a way around that yet 😕

In the beginning I also thought you need a Developer ID, and was pretty shocked to find out it costs A$149 to enroll in the Apple developer program. But as it turns out, it seems like you don’t actually need that. Maybe to actually submit to the app store, but I’m not planning on doing that anyway. As far as I can tell, code signing seems to work fine without it.

Steps to get your certificate:

  • go to Xcode
  • go to Preferences > Accounts
  • select your account (or add it)
  • click Manage certificates
  • add Apple Development certificate

You can download the certificate by right clicking (e.g. cert.p12), when you do that you also specify the password for that certificate. When building on your machine, Electron will automatically take the certificate from the keychain. You only need to download it if you want to use it on any other machine and/or CI.

Useful resources:

7. CI using Github Actions

I usually use Gitlab cause that’s what I use at work. But I wanted to use the opportunity to play around with Github Actions.

My first impression: it’s amazing! Really straight forward to set up, use and just overall great developer experience. If you haven’t played around with it but want to look into CI and autmated workflows, definitely take a look.

Github allows you to select the latest macOS to run your workflows on, which is awesome for building apps for different platforms. To code sign in CI, all you need to do is to base64 the cert and store both the encoded cert and its password in your secrets and add them to the env variables for the build command.

Here my Github action to deploy one of my side projects:

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    branches: [master]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  build:
    # The type of runner that the job will run on
    runs-on: macos-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Check out repo
      - uses: actions/checkout@v2

      - name: Use Node.js 12.0
        uses: actions/setup-node@v1
        with:
          node-version: 12.0

      - name: Install yarn
        run: curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.16.0

      - name: Install dependencies
        run: yarn && cd js && yarn && cd -

      - name: Create env file
        run: echo "module.exports = {};" > js/src/env.js

      - name: Build
        env:
          CSC_LINK: ${{ secrets.MACOS_CERT }}
          CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
        run: |
          yarn electron:build --publish always
          rm -rf dist/mac

      - name: Deploy to surge
        env:
          SURGE_LOGIN: ${{ secrets.SURGE_LOGIN }}
          SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }}
          RELEASE_URL: ${{ secrets.RELEASE_URL }}
        run: yarn surge dist $RELEASE_URL

Other generally good resources that helped me: