The ultimate Electron guide

The ultimate Electron guide

You have just taken the next step to invest in yourself and your skills by reading this guide! In this post, we will be taking a deep dive into the Electron JS ("Electron") framework, learning how everything works and how it was designed to fit together. We will build our knowledge incrementally, so by the end of the post, you will have a clear understanding of the Electron framework and be able to use this new knowledge with your creativity to build the app you have been dreaming of. Let's begin!


The 10,000ft view

Native desktop apps require specialized knowledge per platform in order to develop them. To write a desktop app on Windows would likely use WPF. To write a desktop app on Mac requires knowledge of the Cocoa framework. To write a desktop app on Linux might leverage Gtk or GNOME. You might choose to write an app in a platform-specific language if you want to:

  • Provide a low memory footprint for your app
  • Provide users with a very fast and responsive app
  • Support a client who does not need cross-platform support

In today's world, memory is cheap and time is expensive. Nine times out of ten it is better to be first to market than to come in second. This environment we live in has created incentives to prioritize portability over performance if we want our app to succeed in this highly competitive space; this is where Electron comes in.

Electron leverages common languages, Javscript, HTML and CSS, in order for you to quickly scaffold and refine your app for cross-platform use.

How does Electron support multiple platforms?

The Electron framework bundles Chromium, the open source, cross-platform browser and Node into one framework. Chromium is what renders the screens/windows of your Electron app, while Node provides OS-level functionality (think file access, database access, etc.) for your application.

Since Electron uses Chromium, we have cross-platform support: every operating system will use the same bundled version of Chromium. This feature is one benefit Electron has over Tauri (another cross-platform framework) - your app will look and behave exactly the same on any supported operating system.

How do Chromium and Node interact with each other?

Before we are able to explain how Chromium and Node interact within the Electron framework, we will first review the project structure of an Electron application. We will then discuss various dependencies an Electron application may have, along with the version of Electron, and how that changes how Chromium and Node interact with each other in the Electron framework.

An Electron app is a npm project that includes the electron framework as a dependency. The main entry point of the npm project is the entry point of an Electron app where we optionally include any Node APIs we desire, as well as create the browser that renders a UI.

An Electron application is a NPM project that has the Electron framework as a dependency
The internals of an Electron app

From here, the architecture of your own Electron app may differ in possibly one of these ways:

  • You may need your Electron app to access a database or other external service/system
  • You may need to leverage one or many of the Node APIs in your app
  • You may choose to follow today's common practice and bundle all of your front-end assets in a single Javascript file
  • You may need a combination of the above options

If you were not following this guide and instead looking at piecemeal code examples on the internet, it is highly likely that you'd have troubles at this point when writing your first Electron application. Why? Electron is currently on version 16 as of this blog post. A majority of Electron resources online were written before version 5 shipped. In version 5 of the Electron framework, a breaking change was introduced that favored building more secure apps, but this required a fundamental shift of how Electron apps need to be architected. If you are reading an Electron guide, it was likely written before version 5 released, which does not set you up for success with the most current version of the framework.

We will review what this breaking change introduced in version 5 of Electron was, but before we do this - we need to take a dive and understand the internals of Chromium.

Understanding Chromium

Chromium starts from it's main process. From the main process, renderer processes can be spawned. Renderer processes are synonymous with a [browser] window. The main process holds references to renderer processes and can create/delete renderer processes as necessary. In most Electron apps, only one renderer process is created, but there is no hard limit of 1 if more renderer processes are necessary.

The Chromium main process holds references to renderer processes
The main process holds references to renderer processes

If we inject this diagram into our previous image, this is where Chromium lives in our npm [Electron] project.

Chromium is created within the main entry point as defined in the package.json
Chromium is spawned within the main script in a npm [Electron] project

Pictures are great for understanding, but when rubber hits the road, we need to know how we can create renderer processes with code. See the below diagram how we can create renderer processes.

The Electron renderer process is synonymous with an Electron BrowserWindow
Creating a renderer process via the BrowserWindow

That's it! Chromium exists in our Electron app by managing one to more BrowserWindows. We create a BrowserWindow (or additional as necessary) when our application starts up, and we delete the BrowserWindow (or additional) when the application shuts down. This lifecycle management of BrowserWindows is very easy and handled by Javascript code.

Surprise! If your Electron app is a simple webpage with no connection to external systems (databases, filesystems, etc.) or Node APIs, then not all of the details of Chromium are critically relevant for your Electron app. I do assume that your application does use one of these external libraries/connections, and this adds complexity we need to understand depending on what version of Electron we are targeting.

Pre-version 5 of Electron

As an example, let's assume our Electron application is to replicate a file explorer. Our Electron application should have access to the operating system's file system and be able to create or delete files. Filesystem access and management is handled by the fs Node API. In versions of Electron prior to 5, each renderer process had direct access to Node and could import fs as necessary.

Renderer processes in Electron versions before 5 have default access to Node APIs
Our renderer process have access to all the Node APIs
💡
I've omitted the (node) bubble around the main process, since the main process already has access to the Node APIs automatically.

The property that allowed us to include Node in the renderer process is nodeIntegration: true. This made our code easy - our front end Javascript could manage files as we needed and things worked as expected, except when you have someone motivated to attack your systems.

You see, sticking Node in the renderer process opens up our applications to RCE (remote code execution) attacks. Through unique ways, a motivated individual can open up the developer tools on your Electron app (remember, the window is simply a Chromium browser), find the reference to fs, and boom - there goes all of your files.

There are a number of preventative steps one can take to upgrade and patch this vulnerability, but by and large we need to move away from nodeIntegration: true. This is why in version 5 did the Electron team set nodeIntegration to false by default.

Electron version 5+ and [Chromium] IPC

Using our same example as a file explorer, let's explore how we can create or delete files beginning with Electron v5.

We can no longer use Node within the renderer process (since nodeIntegration is false by default), but we still have Node access in our main script, can we leverage that instead?

Node is removed from the renderer process. Instead, let us load the Node APIs in the main process.
Can we load the fs Node API in the main script instead of the renderer process?

We can! Instead of interacting with fs in the renderer processes, we instead interact with fs in our main process. In order to do so, we need to introduce IPC (inter-process communication), a detail of Chromium we have not yet covered.

If we simplify our diagram, let's assume we have an individual renderer process; this scenario would be common for Electron applications with a single window. In this example, our main process creates and stores a reference to the renderer process.

A Chromium example where a single renderer process is maintained by the main process
Chromium with a single renderer process

While it may seem like it, neither the main nor the renderer process can talk to each other. The main process only has a reference to the renderer process; the processes are split up due to sandboxing. In order for communication to flow between the main and renderer process, we must use IPC.

💡
If you hear any mention of a "remote" module Electron had - avoid this. This was a precursor to IPC and should no longer be used in your Electron app. Here's a bit more detail on the topic if you are interested.

IPC stands for inter-process communication and is an event-based driven design. When implementing IPC, each process sets up listener functions that will activate/run when the given message is received.

The main and renderer process can have any number of listener methods that can talk to another process via IPC
Listener methods A, B and C are on the main process. Listener methods D and E are on the renderer process

IPC listeners are defined by a name, or "channel". The way in which each process can talk to one another is by sending messages to the others' channel that has a listener defined. Below, you can find a simplified sample of this in practice.

main

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

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;

async function createWindow() {

  // Create the browser window.
  win = new BrowserWindow({
    width: 800,
    height: 600
  });

  // Load app
  win.loadFile(path.join(__dirname, "dist/index.html"));

  // rest of code..
}

app.on("ready", createWindow);

// Create an listener for the event "A"
ipcMain.on("A", (event, args) => {
    
  // Send result back to renderer process
  win.webContents.send("D", {success: true});
});

renderer

const {
    ipcRenderer
} = require("electron");

window.sendToA = function(){
    ipcRenderer.send("A");
};

window.receiveFromD = function(func){
    ipcRenderer.on("D", (event, ...args) => func(event, ...args));
};

You likely have one or more of these questions:

  • How do we require("electron") in our renderer process? Didn't you say that this isn't allowed any more?
  • Isn't the renderer supposed to be the window? I don't see any HTML.
  • Why are you declaring functions on the window ?

Preload script and security

I have purposely glossed over an important detail that's required when communicating over IPC between a main and renderer process, and that's what the Electron team have defined as the preload script.

Since our renderer processes have no access to Node, they themselves can't create the IPC bindings that they need! The answer to this problem is through the preload script, which has access to Node APIs. It is in the preload script that we create our renderer IPC bindings; the contents of the preload script are injected into our renderer process (this is why we are defining functions on the window).

The preload script is the place where IPC listeners are defined for the renderer process
The preload script is where we define IPC bindings for our renderer process

In our code, this is how we define the preload script for our given renderer process.

// Create the browser window.
win = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    preload: path.join(__dirname, "preload.js")    
  }
});

Unfortunately, there arose a security flaw in the preload design. At any point could someone or some malicious script open up the browser tools of your Electron app and change the definition of a function you've defined in the preload, and then your Electron app may be vulnerable. Let's review a simple example of how this vulnerability might play out and what you should not be doing.

A friendly reminder, this is what not to do!

preload

const {
    ipcRenderer
} = require("electron");

window.send = function(){
    return ipcRenderer.send;
};

window.receive = function(){
    return ipcRenderer.on;
};

renderer

<!doctype html>
<html lang="en-US">
<head>
    <meta charset="utf-8"/>
    <title>Title</title>
</head>
<body>
    <script>
        // Called when message received from main process
        window.receive()("fromMain", (data) => {
            console.log(`Received ${data} from main process`);
        });

        // Send a message to the main process
        window.send()("toMain", "some data");
    </script>
</body>
</html>

The above code allows us to arbitrarily send or receive any message to our main process. While having the flexibility saves us development time, a motivated attacker can change the definition of window.receive or window.send and start sending any IPC message they desire to your main process. We run into a problem when your main process looks like this.

main

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

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;

async function createWindow() {

  // Create the browser window.
  win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, "preload.js") // use a preload script
    }
  });

  // Load app
  win.loadFile(path.join(__dirname, "dist/index.html"));

  // rest of code..
}

app.on("ready", createWindow);

// Create a listener for deleting files
ipcMain.on("deleteFile", (event, args) => {
  fs.unlinkSync(args.filePath);
    
  // Send the result back to the renderer process
  win.webContents.send("deleteFileResponse", true);
});

If we make the mistake of trusting our renderer processes, and the front-end of our code gets hacked to send a list of file paths to the "deleteFile" channel, we might lose critical files on our computer. This might be a contrived example, but it goes to show if we are not careful, someone might leverage our code to execute actions we had not intended.

In order to use IPC without being vulnerable to function overriding, we need to make use of context isolation. Put simply, context isolation creates an immutable object of what you define in the preload. Anything that has context isolation cannot be changed by the renderer process. Luckily, setting up context isolation is very easy.

💡
Since version 12 of Electron, the value of contextIsolation defaults to true.
// Create the browser window.
win = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    contextIsolation: true,
    preload: path.join(__dirname, "preload.js")    
  }
});

Our preload needs to change slightly to support context isolation; we need to use the contextBridge.

const {
    ipcRenderer,
    contextBridge
} = require("electron");

// Expose protected methods off of window (ie.
// window.api.sendToA) in order to use ipcRenderer
// without exposing the entire object
contextBridge.exposeInMainWorld("api", {
    sendToA: function(){
        ipcRenderer.send("A");
    },
    receiveFromD: function(func){{
        ipcRenderer.on("D", (event, ...args) => func(event, ...args));       
    }
});
💡
The contextBridge allows us to inject/add a key (in our case, "api") on the window, with an object where we can define any number of properties that are available within the renderer process.

renderer

<!doctype html>
<html lang="en-US">
<head>
    <meta charset="utf-8"/>
    <title>Title</title>
</head>
<body>
    <script>
        // Called when message received from main process
        window.api.receiveFromD((data) => {
            console.log(`Received ${data} from main process`);
        });

        // Send a message to the main process
        window.api.sendToA();
    </script>
</body>
</html>
💡
Once you start leveraging the context bridge more, you'll want to make sure you read up on the known limitations of what and what you cannot put on the context bridge.

If we want to take our security to the final step, we can enable sandbox mode. Sandbox mode does what you expect it to, it provides another layer of protection in the case that all of our protections fail, our Electron app won't affect our underlying operating system. Enabling sandbox mode removes all but a very limited number of Node modules. I would recommend creating every new Electron application with sandbox mode as in future versions of Electron, sandbox mode will by default be set to true (this doesn't mean you are forced to use sandbox mode if you don't want to or cannot).

To enable sandbox mode for your Electron app, set the value in webPreferences.

// Create the browser window.
win = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    contextIsolation: true,
    sandbox: true,
    preload: path.join(__dirname, "preload.js")    
  }
});
💡
If you are writing code that keeps Node APIs and references in your main process, you will be easily able to support sandbox mode.

Many existing Electron packages are not authored in this way, so public package support is somewhat lacking at the time this blog post was written.

Section wrap up

We sure did go through a lot of information, here is what you should remember.

For any Electron application

  • Chrome exists in two processes; a main process and one or more renderer process.

For an Electron application that needs to use Node APIs or any other external service/process

  • Node APIs should be require in the main process.
  • Communication between the main and renderer process should use IPC.
  • The preload must be used to define IPC listener functions for the renderer process, this is done through the contextBridge.
  • contextIsolation should be set to true.
  • sandbox should be set to true [if possible].