I recently started my first electron project because I thought it was the quickest way to build a user interface for a desktop app that I was developing. But the project setup turned out to be much more difficult than I had anticipated because I ran into many errors that I could find very little information about online and which I had to solve through tedious trial-and-error. However, in the process, I have learned a lot about the architecture of electron apps and – most importantly – how to fend off the most significant security risks. In this article, I want to share some of these things. Maybe it can save you part of the frustration that I experienced.

I’ll assume that you have some knowledge of JavaScript, Vue and Node, but other than that, I’ll try to explain every step along the way.

Scaffolding an electron project with Vue

We will use the Vue CLI for setting up the project, so the first step is to install the CLI globally. We will also use yarn instead of npm because it’s recommended by some of the packages that we will be using.

yarn install -g @vue/cli

Next, we are going to initialize the project. In the directory where you typically store your projects, type

vue create <project-name> && cd <project-name>

The Vue CLI will guide you through the configuration and scaffold the project files. I’ve chosen Vue 2 and kept the defaults, except for enabling CSS Pre-processors.
If you haven’t installed electron yet, go to electronjs.org and download the latest release. To launch or distribute our electron app, we have to build and package it. We could do this by hand, but some npm packages can automate it for us. We are going to use electron-builder for this.

vue add electron-builder

When you use the Vue CLI to add the package, it will install vue-cli-plugin-electron-builder, create the required electron files and add two new commands to the package.json file: electron:serve and electron:build. The first one starts a development server that hot-reloads your code when it changes (at least the renderer code, more on this later). The latter one builds and packages your application. On Windows, this creates a ready-to-use installer.
Here’s an overview of the files that the Vue CLI has created for us so far:

public/
|- favicon.ico
|- index.html
src/
|- assets/
|  |- logo.png
|- components/
|  |- HelloWorld.vue
|- App.vue
|- background.js
|- main.js
...

The public/ folder contains static assets that will be copied to the output directory when you build the app. It also includes the index.html file, with the <div> tag into which the Vue app gets mounted. The background.js file is the entry point of the background process, and main.js is the entry point of the renderer process. I will explain the difference between the two processes shortly. The Vue app is defined in App.vue and uses a single component: HelloWorld.vue. Lastly, you have the assets/ directory. You can store images, stylesheets or fonts in here, but they will only be included in the build if you import them in javascript. See webpack asset management for more info.

We aren’t finished with the setup yet, but you can start the app at this point.

yarn electron:serve

Success 🎉 This was pretty straightforward. Next, I wanted to use the node file system module in the Vue app to work with files on the user’s computer. But when I added const fs = require('fs') to App.vue, I got the following error:

Uncaught ReferenceError: require is not defined

I searched for this error related to electron and stumbled upon different articles explaining how to use native node modules with electron. But none of these articles explained how to do this when you are using webpack, so no matter what I tried, it didn’t work. In the end, it turned out that I didn’t have enough knowledge of how electron apps work to understand what was happening here, so I’ll try to give you a brief overview of the architecture of electron apps.

The architecture of electron apps

When you started your electron app, you might have noticed that it spawned multiple processes. Electron is built on chromium, so like your chrome browser, it creates one renderer process for every window that your app uses and one additional process for the backend, which electron calls the main process. Now, open up the developer tools of your web browser and type require('fs'). Surprise! This is the same error that I got above.

Electron apps work very similar to server/client connections on the web: the backend (server) is completely isolated from the frontend (client). Only the backend process runs on node.js, and require is only available in the node.js context. Therefore, the client code doesn’t understand what require means and also has no access to any node modules, like the fs module.

I had assumed that electron runs the client code on node.js as well, and when I found out it doesn’t, I was frustrated and wondered why it had to be this complex. But the reason to isolate the node.js context is that it might pose a severe security risk to make it available on the client because the node modules would give the user full access to the operating system. You can change this through the nodeIntegration argument of the BrowserWindow constructor, but I really don’t recommend this (and I couldn’t get it to work with webpack anyway). Even if you trust your users, your app will expose a huge attack vector for malicious programs when you enable node integration. It’s pretty easy to delete all files on your computer with the fs module.
So what do we do? We will move our application logic to the backend, and whenever the renderer process needs to use a node module, it delegates the task to the backend process. To communicate between frontend and backend we will use inter-process communication (IPC), which is built into electron precisely for this purpose.

The backend process can use the ipcMain module, and the renderer process has access to ipcRenderer. The docs explain how to send and receive messages, but what they don’t explain is how to use ipcRenderer in the renderer process. After all, it’s part of the electron node module, and as we’ve already seen, node modules are not available on the client-side. 🤔 We will change that by adding a preload script.

Using a preload script to add IPC to the renderer

After struggling to get this to work for a long time, I stumbled upon this StackOverflow post that explains the use of a preload script in an electron app. The BrowserWindow constructor accepts a preload argument that should be an absolute path to a script file. The script will be invoked during the creation of the BrowserWindow and has access to all node modules. With the help of electron’s contextBridge API, which allows us to expose objects to the renderer process through the global window object, we can use the preload script to publish (part of) a node module to the renderer process.
The contextBridge.exposeInMainWorld function expects two arguments: a name and an object. It copies the object to the global window instance with the given name. We will use the preload script to add IPC functions to the renderer process. We will have to reference the script’s file path later, so it has to be a static asset to be copied to the build directory when the app is compiled. Let’s create a public/preload.js file with the following contents:

// public/preload.js

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

const validChannels = ['READ_FILE', 'WRITE_FILE'];
contextBridge.exposeInMainWorld(
  'ipc', {
    send: (channel, data) => {
      if (validChannels.includes(channel)) {
        ipcRenderer.send(channel, data);
      }
    },
    on: (channel, func) => {
      if (validChannels.includes(channel)) {
        // Strip event as it includes `sender` and is a security risk
        ipcRenderer.on(channel, (event, ...args) => func(...args));
      }
    },
  },
);

Once we have registered the script, the renderer process will be able to use window.ipc.send() and window.ipc.on() to communicate with the backend. It’s important that we only expose the functions we need because giving the user access to the complete module would be insecure. You might also be wondering why we only accept whitelisted communication channels. We will get to that in a second, but first, let’s register the script during the BrowserWindow creation:

// src/background.js

const path = require('path');
// ...
const win = new BrowserWindow({
  // ...
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    enableRemoteModule: false,
    // __static is set by webpack and will point to the public directory
    preload: path.resolve(__static, 'preload.js'),
  },
});

Eslint will complain that __static is undefined, so let’s add it to the .eslintrc.js:

// .eslintrc.js

module.exports = {
  // ...
  globals: {
    __static: 'readonly',
  },
}

It would suffice to only pass the preload script path to the BrowserWindow constructor, but the additional arguments will make our app a little more secure. nodeIntegration is set to false by default, but the default used to be true, so I like to set this explicitly. The same applies to the enableRemoteModule argument, which would give us access to the electron module from the renderer process and would also pose a security risk. contextIsolation will make sure that our preload script runs in a separate context from the renderer process. Without it, we wouldn’t have to use the contextBridge API in the preload script and could just do something like this:

// public/preload.js

window.ipc = {
  send: (channel, data) => { ... },
  on: (channel, func) => { ... },
};

Why do we go the extra mile then? The problem is that the shared context poses the risk of prototype pollution. Take a look at this hypothetical code:

<!-- src/App.vue -->

<script>
export default {
  name: 'App',
  mounted() {
    Array.prototype.sort = () => {
      fs = require('fs');
      fs.deleteEveryFileOnYourComputer();
    };
  },
};
</script>
// public/preload.js

longRunningWebRequest().then((dataArray) => {
  dataArray.sort();
  window.data = dataArray;
});

When the app gets mounted, it overwrites the Array.sort function with malicious code. If the preload script runs within the same context as the renderer process and the web request finishes after the app was mounted, the promise handler will erase all files on the user’s system instead of sorting the array. This technique is called prototype pollution, and we can prevent it by isolating the preload script context from the context of the renderer script. The client can still pollute the Array prototype, but it will only affect the client code, which is isolated from the operating system and can do little harm.

With this setup, the client can safely communicate with the backend. But why did we have to whitelist the communication channels? To understand this, you have to know that the electron core itself uses IPC channels for communicating between the front- and backend. I won’t show you the details of how this can be exploited because it’s out of scope for this blog post, but you should know that these internal IPC channels can be highjacked to launch arbitrary programs on your user’s computer. That’s why we only accept whitelisted communication channels. If you want to read more about it, you can check out this post that I found on another blog.

In order to send a command from the renderer to the main process you can now do this:

<!-- src/App.vue -->

<script>
export default {
  name: 'App',
  mounted() {
    // handle reply from the backend
    window.ipc.on('READ_FILE', (payload) => {
      console.log(payload.content);
    });
  },
  methods: {
    readFile(path) {
      // ask backend to read file
      const payload = { path };
      window.ipc.send('READ_FILE', payload);
    },
  },
};
</script>

And the main process can handle messages like this:

// src/background.js

const { ipcMain } = require('electron');
const fs = require('fs');

ipcMain.on('READ_FILE', (event, payload) => {
  const content = fs.readFileSync(payload.path);
  event.reply('READ_FILE', { content });
});

Great, getting this to work was the most difficult part. 🎉 We now have an electron app with a Vue frontend, that can safely send commands to the main process to use functions that are only available in native node modules. You can stop here if you want, but I was annoyed by the project’s directory structure. If you keep reading, I’ll show you how to reorganize your files in the next section.

Changing the default directory structure

Your current directory structure should look like this:

src/
|- assets/
|- components/
|- background.js
|- main.js
|- App.vue
...

The background.js file is the entry point for electron’s background process, and the main.js file is the entry point of the renderer process. Both files are independent of one another, so I prefer to keep them in separate folders. Something like this:

src/
|- background/
|  |- main.js
|  ...
|- renderer/
|  |- assets/
|  |- components/
|  |- main.js
|  |- App.vue
|  ...
|- shared/
   ...

When we move the files, we need to inform webpack about the changed paths to the entry points. In a Vue CLI project, you configure webpack through a vue.config.js file in the project root.

touch vue.config.js

The electron-builder plugin adds additional options to the webpack configuration. These allow us to change the file paths to the entry points. It took me a while to figure this out because it differs from how you would change the webpack entry points without the electron-builder plugin.

// vue.config.js

module.exports = {
  pluginOptions: {
    electronBuilder: {
      mainProcessFile: 'src/background/main.js',
      rendererProcessFile: 'src/renderer/main.js',
    },
  },
};

For more information on how to configure webpack with electron-builder have a look at the vue-cli-plugin-electron docs.

What confused me was that the package.json contains this entry:

"main": "background.js"

I thought that I had to change this to match the new directory structure, but I didn’t have to. Actually, the app will no longer compile if you change this, so keep the entry as it is. That’s all there is to changing the entry paths.

Wrapping up

Let’s recap what we have done. First, we have created an electron project with the Vue CLI. Then we learned how to safely expose functions to the renderer process and have made some tweaks to patch a few security holes. Lastly, we reorganized the default directory structure to separate the background code from the renderer code.

Hopefully, by sharing what I have learned, I could help you avoid some of the trouble I initially had and understand more about electron in general. You definitely have a good starting point for your next electron project now. Of course, you can enhance the preload script and add more functions to window.ipc. How about an invoke method for simpler remote function calls, for example?