How to Set Up an Electron App With Vue and Webpack
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.
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
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:
| |- logo.png
| |- HelloWorld.vue
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
We aren’t finished with the setup yet, but you can start the app at this point.
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
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
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:
Once we have registered the script, the renderer process will be able to use
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
Eslint will complain that
__static is undefined, so let's add it to the
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:
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:
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:
And the main process can handle messages like this:
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:
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:
| |- main.js
| |- assets/
| |- components/
| |- main.js
| |- App.vue
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.
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.
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:
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.
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?