Build a Secure Desktop App with Electron Forge and React

    Kilian Valkhof
    Share

    In this article, we’ll create a simple desktop application using Electron and React. It will be a small text editor called “scratchpad” that automatically saves changes as you type, similar to FromScratch. We’ll pay attention to making the application secure by using Electron Forge, the up-to-date build tool provided by the Electron team.

    Electron Forge is “a complete tool for creating, publishing, and installing modern Electron applications”. It provides a convenient development environment, as well as configuring everything needed for building the application for multiple platforms (though we won’t touch on that in this article).

    We’ll assume you know what Electron and React are, though you don’t need to know these to follow along with the article.

    You can find the code for the finished application on GitHub.

    Setup

    This tutorial assumes that you have Node installed on your machine. If that’s not the case, please head over to the official download page and grab the correct binaries for your system, or use a version manager such as nvm. We’ll also assume a working installation of Git.

    Two important terms I’ll use below are “main” and “renderer”. Electron applications are “managed” by a Node.js JavaScript file. This file is called the “main” process, and it’s responsible for anything operating-system related, and for creating browser windows. These browser windows run Chromium, and are referred to as the “renderer” part of Electron, because it’s the part that actually renders something to the screen.

    Now let’s begin by setting up a new project. Since we want to use Electron Forge and React, we’ll head over to the Forge website and look at the guide for integrating React.

    First off, we need to set up Electron Forge with the webpack template. Here’s how we can do that in one terminal command:

    $ npx create-electron-app scratchpad --template=webpack
    

    Running that command will take a little while as it sets up and configures everything from Git to webpack to a package.json file. When that’s done and we cd into that directory, this is what we see:

    ➜  scratchpad git:(master) ls
    node_modules
    package.json
    src
    webpack.main.config.js
    webpack.renderer.config.js
    webpack.rules.js
    

    We’ll skip over the node_modules and package.json, and before we peek into the src folder, let’s go over the webpack files, since there are three. That’s because Electron actually runs two JavaScript files: one for the Node.js part, called “main”, which is where it creates browser windows and communicates with the rest of the operating system, and the Chromium part called “renderer”, which is the part that actually shows up on your screen.

    The third webpack file — webpack.rules.js — is where any shared configuration between Node.js and Chromium is set to avoid duplication.

    Okay, now it’s time to look into the src folder:

    ➜  src git:(master) ls
    index.css
    index.html
    main.js
    renderer.js
    

    Not too overwhelming: an HTML and CSS file, and a JavaScript file for both the main, and the renderer. That’s looking good. We’ll open these up later on in the article.

    Adding React

    Configuring webpack can be pretty daunting, so luckily we can largely follow the guide to integrating React into Electron. We’ll begin by installing all the dependencies we need.

    First, the devDependencies:

    npm install --save-dev @babel/core @babel/preset-react babel-loader
    

    Followed by React and React-dom as regular dependencies:

    npm install --save react react-dom
    

    With all the dependencies installed, we need to teach webpack to support JSX. We can do that in either webpack.renderer.js or webpack.rules.js, but we’ll follow the guide and add the following loader into webpack.rules.js:

    module.exports = [
      ...
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            exclude: /node_modules/,
            presets: ['@babel/preset-react']
          }
        }
      },
    ];
    

    Okay, that should work. Let’s quickly test it by opening up src/renderer.js and replacing its contents with the following:

    import './app.jsx';
    import './index.css';
    

    Then create a new file src/app.jsx and add in the following:

    import React from 'react';
    import ReactDOM from 'react-dom';
    
    ReactDOM.render(<h2>Hello from React in Electron!</h2>, document.body);
    

    We can test if that works by running npm start in the console. If it opens a window that says “Hello from React in Electron!”, everything is good to go.

    You might have noticed that the devtools are open when the window shows. That’s because of this line in the main.js file:

    mainWindow.webContents.openDevTools();
    

    It’s fine to leave this for now, as it will come in handy while we work. We’ll get to main.js later on in the article as we configure its security and other settings.

    As for the error and the warnings in the console, we can safely ignore them. Mounting a React component on document.body can indeed be problematic with third-party code interfering with it, but we’re not a website and don’t run any code that’s not ours. Electron gives us a warning as well, but we’ll deal with that later.

    Building Our Functionality

    As a reminder, we’re going to build a small scratchpad: a little application that saves anything we type as we type it.

    To start, we’ll add CodeMirror and react-codemirror so we get an easy-to-use editor:

    npm install --save react-codemirror codemirror
    

    Let’s set up CodeMirror. First, we need to open up src/renderer.js and import and require some CSS. CodeMirror ships with a couple of different themes, so pick one you like, but for this article we’ll use the Material theme. Your renderer.js should now look like this:

    import 'codemirror/lib/codemirror.css';
    import 'codemirror/theme/material.css';
    import './app.jsx';
    import './index.css';
    

    Note how we import our own files after the CodeMirror CSS. We do this so we can more easily override the default styling later.

    Then in our app.jsx file we’re going to import our CodeMirror component as follows:

    import CodeMirror from 'react-codemirror';
    

    Create a new React component in app.jsx that adds CodeMirror:

    const ScratchPad = () => {
      const options = {
        theme: "material"
      };
    
      const updateScratchpad = newValue => {
        console.log(newValue)
      }
    
      return <CodeMirror
        value="Hello from CodeMirror in React in Electron"
        onChange={updateScratchpad}
        options={options} />;
    }
    

    Also replace the render function to load our ScratchPad component:

    ReactDOM.render(<ScratchPad />, document.body);
    

    When we start the app now, we should see a text editor with the text “Hello from CodeMirror in React in Electron”. As we type into it, the updates will show in our console.

    What we also see is that there’s a white border, and that our editor doesn’t actually fill the whole window, so let’s do something about that. While we’re doing that, we’ll do some housekeeping in our index.html and index.css files.

    First, in index.html, let’s remove everything inside the body element, since we don’t need it anyway. Then we’ll change the title to “Scratchpad”, so that the title bar won’t say “Hello World!” as the app loads.

    We’ll also add a Content-Security-Policy. What that means is too much to deal with in this article (MDN has a good introduction), but it’s essentially a way to prevent third-party code from doing things we don’t want to happen. Here, we tell it to only allow scripts from our origin (file) and nothing else.

    All in all, our index.html will be very empty and will look like this:

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title>Scratchpad</title>
        <meta http-equiv="Content-Security-Policy" content="script-src 'self';">
      </head>
      <body></body>
    </html>
    

    Now let’s move to index.css. We can remove everything that’s in there now, and replace it with this:

    html, body {
      position: relative;
      width:100vw;
      height:100vh;
      margin:0;
      background: #263238;
    }
    
    .ReactCodeMirror,
    .CodeMirror {
      position: absolute;
      height: 100vh;
      inset: 0;
    }
    

    This does a couple of things:

    • It removes the margin that the body element has by default.
    • It makes the CodeMirror element the same height and width as the window itself.
    • It adds the same background color to the body element so it blends nicely.

    Notice how we use inset, which is a shorthand CSS property for the top, right, bottom and left values. Since we know that our app is always going to run in Chromium version 89, we can use modern CSS without worrying about support!

    So this is pretty good: we have an application that we can start up and that lets us type into it. Sweet!

    Except, when we close the application and restart it again, everything’s gone again. We want to write to the file system so that our text is saved, and we want to do that as safely as possible. For that, we’ll now shift our focus to the main.js file.

    Now, you might have also noticed that even though we added a background color to the html and body elements, the window is still white while we load the application. That’s because it takes a few milliseconds to load in our index.css file. To improve how this looks, we can configure the browser window to have a specific background color when we create it. So let’s go to our main.js file and add a background color. Change your mainWindow so it looks like this:

    const mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      backgroundColor: "#263238",
    });
    

    And now when you start, the flash of white should be gone!

    Saving our scratchpad on disk

    When I explained Electron earlier in this article, I made it a little simpler than it is. While Electron has a main and a renderer process, in recent years there’s actually been a third context, which is the preload script.

    The idea behind the preload script is that it acts as a bridge between the main (which can access all the Node.js APIs) and the renderer (which should definitely not!). In our preload script we can add functions that can talk to the main process, and then expose them to the renderer process in such a way that it doesn’t impact the security of our application.

    So let’s get an overview of what we want to do:

    • When the user makes a change, we want to save it to the disk.
    • When the application launches, we want to load back in that stored content from disk, and make sure it shows in our CodeMirror editor.

    First, we’ll write code that lets us load and store content to disk in our main.js file. That file already imports Node’s path module, but we also need to import fs to do things with the file system. Add this to the top of the file:

    const fs = require('fs');
    

    Then, we’ll need to choose a location for our stored text file. Here, we’re going to use the appData folder, which is an automatically created place for your app to store information. You can get it with the app.getPath feature, so let’s add a filename variable to the main.js file right before the createWindow function:

    const filename = `${app.getPath('userData')}/content.txt`;
    

    After that, we’re going to need two functions: one to read the file and one to store the file. We’ll call them loadContent and saveContent, and here’s what they look like:

    const loadContent = async () => {
      return fs.existsSync(filename) ? fs.readFileSync(filename, 'utf8') : '';
    }
    
    const saveContent = async (content) => {
      fs.writeFileSync(filename, content, 'utf8');
    }
    

    They’re both one-liners using the built-in fs methods. For loadContent, we first need to check if the file already exists (since it won’t be there the first time we launch it!) and if it doesn’t, we can return an empty string.

    saveContent is even simpler: when it’s called, we call writeFile with the filename, the content, and we make sure it’s stored as UTF8.

    Now that we have these functions, we need to hook them up. And the way to communicate these is through IPC, Inter Process Communication. Let’s set that up next.

    Setting up IPC

    First, we need to import ipcMain from Electron, so make sure your require('Electron') line in main.js looks like this:

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

    IPC lets you send messages from the renderer to main (and the other way around). Right below the saveContent function, add the following:

    ipcMain.on("saveContent", (e, content) =>{
      saveContent(content);
    });
    

    When we receive a saveContent message from the renderer, we call the saveContent function with the content we got. Pretty straightforward. But how do we call that function? That’s where things get a little complicated.

    We don’t want the renderer file to have access to all of this, because that would be very unsafe. We need to add in an intermediary that can talk with the main.js file and the renderer file. That’s what a preload script can do.

    Let’s create that preload.js file in the src directory, and link it in our mainWindow like so:

    const mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      backgroundColor: "#263238",
      webPreferences: {
        preload: path.join(__dirname, 'preload.js'),
      }
    });
    

    Then in our preload script we’ll add the following code:

    const { ipcRenderer, contextBridge } = require("electron");
    
    contextBridge.exposeInMainWorld(
      'scratchpad',
      {
        saveContent: (content) => ipcRenderer.send('saveContent', content)
      }
    )
    

    contextBridge.exposeInMainWorld lets us add a function saveContent in our renderer.js file without making the whole of Electron and Node available. That way, the renderer only knows about saveContent without knowing how, or where, the content is saved. The first argument, “scratchpad”, is the global variable that saveContent will be available in. To call it in our React app, we do window.scratchpad.saveContent(content);.

    Let’s do that now. We open our app.jsx file and update the updateScratchpad function like this:

    const updateScratchpad = newValue => {
      window.scratchpad.saveContent(newValue);
    };
    

    That’s it. Now every change we make is written to disk. But when we close and reopen the application, it’s empty again. We need to load in the content when we first start as well.

    Load the content when we open the app

    We’ve already written the loadContent function in main.js, so let’s hook that up to our UI. We used IPC send and on for saving the content, since we didn’t need to get a response, but now we need to get the file from disk and send it to the renderer. For that, we’ll use the IPC invoke and handle functions. invoke returns a promise that gets resolved with whatever the handle function returns.

    We’ll begin with writing the handler in our main.js file, right below the saveContent handler:

    ipcMain.handle("loadContent", (e) => {
      return loadContent();
    });
    

    In our preload.js file, we’ll invoke this function and expose it to our React code. To our exporeInMainWorld list of properties we add a second one called content:

    contextBridge.exposeInMainWorld(
      'scratchpad',
      {
        saveContent: (content) => ipcRenderer.send('saveContent', content),
        content: ipcRenderer.invoke("loadContent"),
      }
    );
    

    In our app.jsx we can get that with window.scratchpad.content, but that’s a promise, so we need to await it before loading. To do that, we wrap the ReactDOM renderer in an async IFFE like so:

    (async () => {
      const content = await window.scratchpad.content;
      ReactDOM.render(<ScratchPad text={content} />, document.body);
    })();
    

    We also update our ScratchPad component to use the text prop as our starting value:

    const ScratchPad = ({text}) => {
      const options = {
        theme: "material"
      };
    
      const updateScratchpad = newValue => {
        window.scratchpad.saveContent(newValue);
      };
    
      return (
        <CodeMirror
          value={text}
          onChange={updateScratchpad}
          options={options}
        />
      );
    };
    

    There you have it: we’ve successfully integrated Electron and React and created a small application that users can type in, and that’s automatically saved, without giving our scratchpad any access to the file system that we don’t want to give it.

    We’re done, right? Well, there’s a few things we can do to make it look a little bit more “app” like.

    “Faster” Loading

    You might have noticed that, when you open the app, it takes a few moments before the text is visible. That doesn’t look great, so it would be better to wait for the app to have loaded, and only then show it. This will make the whole app feel faster, since you won’t be looking at an inactive window.

    First, we add show: false to our new BrowserWindow invocation, and add a listener to the ready-to-show event. There we show and focus our created window:

    const mainWindow = new BrowserWindow({
      width: 800,
      height: 600,
      backgroundColor: "#263238",
      show: false,
      webPreferences: {
        preload: path.join(__dirname, 'preload.js'),
      }
    });
    
    mainWindow.once('ready-to-show', () => {
      mainWindow.show();
      mainWindow.focus();
    });
    

    While we’re in the main.js file, we’ll also remove the openDevTools call, since we don’t want to show that to users:

    mainWindow.webContents.openDevTools();
    

    When we now start the application, the app window shows with the content already there. Much better!

    Building and Installing the Application

    Now that the application is done, we can build it. Electron Forge already has created a command for this. Run npm run make and Forge will build an app and installer for your current operating system and place it in the “out” folder, all ready for you to install whether its an .exe, .dmg or .deb.

    If you’re on Linux and get an error about rpmbuild, install the “rpm” package, for example with sudo apt install rpm on Ubuntu. If you don’t want to make an rpm installer, you can also remove the “@electron-forge/maker-rpm” block from the makers in your package.json.

    This will miss some essential things like code signing, notarization and auto updates, but we’ll leave those for a later article.

    This is a really minimal example of integrating Electron and React. There’s much more we can do with the application itself. Here are some ideas for you to explore:

    • Add a cool desktop icon.
    • Create dark and light mode support based on the operating system settings, either with media queries or by using the nativeTheme api provided by Electron.
    • Add shortcuts with something like mousetrap.js or with Electron’s menu accelerators and globalShortcuts.
    • Store and restore the size and position of the window.
    • Sync with a server instead of a file on disk.

    And don’t forget, you can find the finished application on GitHub.