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 😊
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 🤓
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
requireand
module.exportsin my node scripts, but
importand
exportin my react scripts. To be able to do so, I needed to override the babel
sourceTypeto
unambiguous. To avoid having to eject the project, I used
rescriptsto 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_PATHenv variable to
.
{ "scripts": { "build": "PUBLIC_PATH=. rescripts build" } }
For a smoother development experience, I have the following in my
main.jsto 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
--devto the npm scripts I want to use the dev server for, e.g.
{ "scripts": { "electron:start": "electron . --dev", "electron:build": "electron-builder --publish always"," } }
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
requiremethod 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
fsis 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.requireneed 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.
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-buildermakes it fairly simple to build for any other platform it supports. The integration with
electron-updateris 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
*.icnsfiles 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`, );
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-builderitself 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_modulesin your final bundle, you don’t include any dependencies that you don’t need.
It also helps to customise the
electron-buildersettings, to only include files that are really needed via the
filesoption:
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 😅
As mentioned before,
react-updaterworks 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.jsonis:
{ "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:
latest-*.ymlfrom the url specified in the config
NOTE: for auto updates to work on macOS you need to have your app code signed, so lets move to that next 😊
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:
Preferences > Accounts
Manage certificates
Apple Developmentcertificate
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:
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: