Notes on Electron

For some time I have been intrigued by Electron. I got the basic idea that it is essentially a wrapper for a web application that can act as a cross-platform desktop application.

With that being said, I wanted to go on a journey to learn about building Electron Apps. During this journey, several things felt important for me to note. These were more specifically around the Electron processes and not so much the web technologies which may be used inside the Electron App.

Additional Resource

In this post I make use of a lot of extracts from the official Electron Documentation.

The code snippets below is for reference only and would require additional resource to get running. I created an Electron Apps - Starting Point wiki which contains more detail on these code snippets and can be followed to create a starting point for developing an Electron application with Vue 3, Vite, and Tailwind as the base.

Writing the wiki also resulted in a project demonstrating the IPC patterns discussed later on in this post.

Main Process

Every Electron app has a single main process, which serves several purposes:

  • It acts as the application's entry point.
  • It runs the Node.js environment, which provides access to Node.js built-ins and installed packages, as well as full operating system access.
  • It creates and manages application windows with the BrowserWindow module by loading a web page in a separate Renderer process.

Rendered Process

The Renderer process spawns a BrowserWindow which renders the web content; this includes all user interfaces and app functionality. The entry point for the Renderer process is an HTML file and should be written with the same tools, frameworks, and paradigms that would be used for a typical, or "modern-day", web application.

let win;

const createWindow = () => {
  win = new BrowserWindow({ // Renderer Process instance.
    width: 800,
    height: 600,
    // ...
  });
  
  // ...
  win.loadFile("./dist/index.html"); // Renderer Process entrypoint
};
main.js

By default, a Renderer process does not have access to run Node.js built-ins or installed packages, nor does it have full operating system access, like the Main process does. If this access is enabled for the Renderer process, critical security issues are introduced.

Preload

As previously mentioned, the Renderer process, which is to say the user interfaces, does not interact with Node.js and Electron's native desktop functionality, these features are only accessible from the Main process.

A preload script contains code that executes in a Renderer process before its web content begins loading and is attached to the Main process in the BrowserWindow constructor's webPreferences option.

let win;

const createWindow = () => {
  win = new BrowserWindow({
  	// ...
    webPreferences: {
      preload: path.join(__dirname, "preload.js"), // Attach Preload to the Main Process.
    },
  });
 // ...
};
main.js

The preload script runs within the renderer context, but is granted more privileges by having access to Node.js APIs through exposing arbitrary APIs in the global window that the web contents can then consume.

Simply put, the preload script shares a global Window interface with the Renderer process, but at the same time can access Node.js APIs in the Main process which provides context isolation.

Context Isolation

Context Isolation is implemented through the use of a contextBridge module which exposes inter-process communication (IPC) to securely isolate privileged APIs from the rendered process loaded by the webContents in the Main Process.

Inter-Process Communication

Inter-process communication (IPC) is used to perform many common tasks such as calling a native API from the UI or triggering changes in the web contents from native menus.

This communication occurs by passing messages through developer defined "channels" with the ipcMain and ipcRenderer modules.

This starting point application demonstrates the three different IPC patterns. Each of these patterns are implemented as Vue components.

Electron Demo demonstrating the three IPC patterns

Renderer to Main (one-way)

In this pattern, the preload script exposes an ipcRenderer.send API through a function call to the renderer, which sends a message that is then received by the ipcMain.on API in the Main process.

Renderer

Takes a form input or something similar in a method running in the renderer process and updates the window title in the main process.

set_title(title) {
  window.preload.setTitle(title);
},
Renderer to Main (one-way) - renderer.js

Preload

The contextBridge exposes the following API for the renderer to send on the set-title channel.

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

contextBridge.exposeInMainWorld("preload", {
  setTitle: (title) => ipcRenderer.send("set-title", title),
});
Renderer to Main (one-way) - preload.js

Main

The Main process will call the setTitle function when an event is received on the set-title channel.

const { /* ... */ , ipcMain } = require("electron");

// ...

// Renderer to Main (one-way)
// Set the window title from the renderer
const setTitle = (event, title) => {
  const webContents = event.sender; 
  const win = BrowserWindow.fromWebContents(webContents);
  win.setTitle(title);
};

app.whenReady().then(() => {
  // Renderer to Main (one-way)
  ipcMain.on("set-title", setTitle);
  
// ...

  createWindow();
}
// ...
Renderer to Main (one-way) - main.js

Renderer to Main (two-way)

Similar to the Renderer to Main (one-way) pattern, this pattern will wait for a result from the Main process by using an ipcRenderer.invoke API, paired with ipcMain.handle.

Renderer

A ping button in the renderer process, when clicked, will fire off an event which will receive a pong from the main process.

async ping() {
  const response = await window.preload.ping();
  console.log(response);
},
Renderer to Main (two-way) - renderer.js

Preload

The contextBridge exposes the following API for the renderer to invoke the ping channel.

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

contextBridge.exposeInMainWorld("preload", {
  ping: () => ipcRenderer.invoke("ping"),
});
Renderer to Main (two-way) - Preload

Main

The Main process will call the pong function by handling the ping channel.

// ...

// Renderer to Main (two-way)
// Main responds to ping
const pong = () => {
  return "Pong from Main.";
};

app.whenReady().then(() => {
  // Renderer to Main (two-way)
  ipcMain.handle("ping", pong);

// ...

  createWindow();
}
// ...
Renderer to Main (two-way) - Main

Main to Renderer

The Main process can send a message to the Renderer process via its WebContents instance. This WebContents instance contains a WebContents.send() method that can be used to send an event on a defined channel using the ipcRenderer.on() API in the preload script.

Renderer

Open a file explorer dialog using the menu button to read the file's name and send it to the Renderer process to be displayed.
Optional: The renderer will respond back using a callback function.

window.preload.onFileSelected((_event, value) => {
  console.log("Selected file: " + value)

  // Optional: returning a reply through callback.
  _event.sender.send("file-displayed", true);
});
Main to Renderer - Renderer

Preload

The contextBridge exposes the following API for the renderer to wait on the send-filename channel.

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

contextBridge.exposeInMainWorld("preload", {
  onFileSelected: (callback) => ipcRenderer.on("send-filename", callback),
});
Main to Renderer - Preload

Main

The Main process requires you to add application menu items with a click function call for the selectFile function. This function sends the filename to the renderer on the send-filename channel. The Main process will also wait for an event on the file-displayed channel which the renderer will emit to using the callback.

const { /* ... */ , Menu, dialog } = require("electron");

// ...

// Define window to access the instance in other areas of main
let win;
const createWindow = () => {
  win = new BrowserWindow({ /* ... */ });
  // ..
};

// Add a menu item
const createMenu = () => {
  const menu = Menu.buildFromTemplate([
    { role: "appMenu" },
    { role: "fileMenu" },
    { role: "editMenu" },
    { role: "viewMenu" },
    { role: "windowMenu" },
    {
      label: "Main",
      submenu: [
        {
          label: "Read Filename",
          accelerator: "Alt+CommandOrControl+f",
          click: selectFile,
        },
      ],
    },
  ]);

  Menu.setApplicationMenu(menu);
};

// Main to Renderer
// Send selected filename to renderer
const selectFile = () => {
  const selectedFilesArray = dialog.showOpenDialogSync({
    title: "Select a file",
    properties: ["openFile"],
  });

  if (typeof selectedFilesArray === "undefined") return;

  const selectedFilePath = selectedFilesArray.pop();
  const selectedFile = selectedFilePath.match(/([^/]+)$/g)[0];

  win.webContents.send("send-filename", selectedFile);
};

app.whenReady().then(() => {

  // Optional callback response received from Main to Renderer pattern
  ipcMain.on("file-displayed", (_event, value) => {
    console.log(value); // will print value to Node console
  });

  // ...

  createWindow();
  createMenu();
}
// ...
Main to Renderer - Main

If you enjoyed the post, please consider subscribing so that you can receive future content in your inbox :)

Psssst, worried about sharing your email address and would rather want to hide it? Consider using a service I created to help with that: mailphantom.io

Also, if you have any questions, comments, or suggestions please feel free to Contact Me.