Building desktop apps with web technologies
In this article I share insights into Electron and what to consider when shipping an desktop app with Electron. After that I introduce you to a new alternative called Tauri. It the end I provide an estimation when which tool makes more sense.
It all started with the Atom editor developed by GitHub, where web technologies were used. They packaged it with an Atom shell as a desktop application. The shell was eventually detached and renamed to Electron. Over the years it became part of the OpenJS Foundation and received wide adoption from other companies and open-source apps. Applications such as VSCode, Slack, Skype, Figma, Obsidian, 1Password8 and many others use Electron to ship their applications.
In my daily work routine I often stumble across JWT tokens, timestamps and other encoded data (base64, URI, ...). For debugging purposes I often need more insights or I want to transform it. Pasting application data into random websites is a no-go, so I needed some other tooling. I solved my problem by building a developer scratchpad. It combines tools & transform operations for everyday tasks called CodeWaffle. You can inspect, download and use it open-sourced and free on GitHub. The downloads are in the GitHub releases. This application was built with Electron. The learnings from this article are applied in this codebase.
The Electron architecture overview
Electron consists of a main process and one-to-many renderer processes.
The main process runs in a Node runtime. All the provided Node APIs are accessible including access to the file system. Electron provides an additional API to create native UI elements. It includes native popover dialogues, notifications or an application menu bar setup among others. The main process exists only once per app. When this process terminates, the app will exit. Watch out, because blocking the main process with heavy computation will freeze the entire app! So use async code as much as possible and offload heavy computations to another thread.
The preload script runs at the renderer window context creation. This script allows for bridging custom-developed handlers from the main process to the renderer process. It uses the same sandbox as Chromium plugins.
The Electron architecture has analogies to Chrome's own browser and renderer processes. As you might know, Chromium and V8 are quite memory-intensive, which is a drawback you'll often hear when the conversation is about Electron apps.
Workflow & communication
The startup of Electron works from a high-level perspective in the following steps:
- In the Electron init phase the main process sets up handlers for Electron events.
- When Electron is ready, the main process starts renderer processes and provides a preload script.
- While setting up the window object for the renderer process, the preload script runs and injects additional APIs.
- When the renderer process is ready, it loads and renders the web page.
When everything runs, the main and renderer processes communicate via the API created in the preload script. Those communication options are called Inter-Process Communication (IPC). With these IPC helpers it is possible to communicate back and forth between the main and renderer processes. The communication between two renderer windows with the IPC API is only possible through the main process.
Due to this limitation, the MessagePorts API was introduced. It enables the possibility to create communication channels and then pass messages directly between processes.
Creating an Electron project setup
The project setup consists of two main parts: the dev environment and the bundling and shipping process. The dev setup consists of things like hot-module-reloading for faster feedback loops. The bundling setup packages the app ready for release. For better code quality you can bring all the web utilities you know and use (eslint, prettier, testing-library, ...) but I won't cover them in this article (you can look them up in my linked setup though).
For bundling your Electron apps, you'll eventually come across two libraries:
electron-forge is a great starting point. It's like the
create-react-app that generates you a ready-to-use boilerplate. It takes care of the dev environment, bundling and releasing part. You can directly start coding.
When you need some more customisations or bring some of your own tools,
electron-builder is a nice library that just helps you package the electron app. You can bring all your own tools for everything else.
Thankfully the community already provides helpful resources to get you started.
The vite-electron-builder is a good boilerplate for Electron apps.
One difference I noticed is the updating process for the Electron app:
electron-forge works with the updater built within Electron. It works on Windows and Mac. Linux is required to update their apps with their native package managers.
electron-builder provides its own updater, which ships the auto-updating feature for alle three platforms.
You gain more freedom and flexibility but it also means doing the project setup yourself.
Due to the current setup of Electron, which requires some synchronous tasks, ESM is supported in version 28, but has some limitations you should be aware of. The switch to ESM was complicated and took some time. This only affects the main and preload scripts. The renderer process (Chromium window) is not affected by this limitation. Here you can embrace ESM thanks to the browser support.
But do I have to build both main and preload code purely with CJS now? When you are okay bringing in a transpiler/bundler, it is possible write the complete codebase in ESM and compile the main and preload scripts to CJS.
In my app I was able to work with ESM as a default. I transform the main and preload scripts to CJS in the build step. You can configure using ESM as default for the entire codebase by setting the
"type": "module"entry in your
package.json. You can read about my decision to use ESM here.
While developing an app, we verify everything works correct. At best we automate it.
Test the renderer process–a classical web app–with unit and integration tests. You can bring and use the tool you already know and work with. With clear code patterns you make your life easier testing specific functions. For the main process it makes sense to start with unit tests. You can verify that specific functions work as expected. On these layers we do not want to test external library–Electron–behaviour.
So in the first step these two processes are tested separately. The preload script might not include and therefore require that much testing. You can still build some unit tests though. Using TypeScript, you even gain type-safety for the communication.
But the questions remains: How do I verify that my feature works from a user perspective? How do I automate it with E2E tests?
There are some toolkits that support testing Electron apps. The ones I found:
I tried Playwright with my setup. Yes, it is experimental, but it worked fine. You can start the entire Electron app and automate the usage from a user perspective. It's a great way to make sure that features work throughout the entire app.
Road to production of Electron apps
So with the setup in place, how do I ship the app?
For CodeWaffle I only have a macOS deployment. Windows is a bit different in terms of the code signing process (you'll read about it shortly).
Security in Electron
Due to numerous security vulnerabilities in the way Electron apps have been developed in the past, there are some aspects to look out for when shipping an app. It was commonsensical to use the Node native system APIs in the renderer process. One process to load and execute remote resources has full native system access. Bad idea. So we got the preload script.
Among other things they introduced the Process Sandboxing and Context Isolation. Those are closer to the Chromium security architecture and introduce a context bridge to prevent sharing global objects across processes.
Besides those internal structures, it is recommended to load only secure content from external sources. Even better, bundle assets and code directly with the app. Bundled data is part of the app signing and therefore spared from various attacks that can happen to a remote server. But it means the continuous deployment looks very different than the pipeline for a web app. App updates require some user interaction.
This is just an short outline and some highlights. To really make sure your app aligns with the latest security standards, Electron provides a big security checklist to go through. Thankfully there are more helpful resources for learning and validating your setup: With the launch of 1Password 8 they shared their learnings building secure Electron apps and provide a helpful secure-defaults example setup. The tool electronegravity can help you identify misconfigurations and security anti-patterns in Electron applications. It leverages AST and DOM parsing to look for security-relevant configurations. But there are limitations of the AST/DOM parsing which means you may encounter false positive results. It is a really nice helper, but you have to do the verification yourself!
Coming from an web app development background, you have to consider more things when building a desktop app that feels native to each operating system. Here are some highlights you want to consider:
- Multiple windows: Most platforms show the app preferences in a separate window. Doing this in you Electron app as well brings a native app feeling.
- The window position and size is restored when reopening a window. You need to take care of the positioning yourself.
- Each app has a Menu with items like "File", "Edit", "View", "Window", "Help", etc. in der top left corner.
- Dropdowns – context menus – on an operating system layer have their native theme and design. You should integrate those instead of using your own (as you might solve it in your web app).
There are some great blog articles and talks to help you with you setup:
- Making Electron apps feel native on Mac
Surely there are some things you already know. Page speed and performance optimisations are some of them. Primarily the first window load is about getting the window rendered as fast as possible. A great talk covers how it is handled inside VSCode: CovalenceConf 2019: Visual Studio Code – The First Second
Shipping includes bundling, code signing and releasing
With the bundle process and package step, the app is ready to use. Unfortunately others are not able to install it directly. Desktop apps on Windows and macOS require an app sign and notarize process before others are able to install the app. To do that, we need a code signing certificate. You get macOS certificates via an Apple Developer account (includes a yearly fee). Windows certificates can be purchased by various vendors. For each platform you have to purchase an appropriate certificate.
This extra step is required, but you can automate it and include it in your release pipeline. Since others already figured that part out, here are the articles that helped me:
- Notarizing your Electron application
- Signing Electron Apps with GitHub Actions - DEV Community
- Code-signing Electron Apps in CI
- Signing and notarizing an Electron app for distribution using GitHub Actions | Simon Willison’s TILs
- How I sign and notarize my Electron app on MacOS | Fun to Imagine
- How to deploy an Electron app with auto-updates enabled for free in 2021 with just one command
The new kid on the block: Tauri
With the popularity of Electron and its drawbacks – heavy memory consumption, more effort to nail security and licensing issues – new tools emerged. One tool I want to highlight is Tauri. They saw the popularity of Electron and built a new foundation based on learnings from past mistakes. Instead of Node and Chromium, they use Rust for the core process and WebView for the renderer processes. Security is not an afterthought, but directly baked into the core architecture.
So what should I go with?
As always, it depends. Electron is the save bet. You can be pretty sure that it will work or there is already a ticket with an open discussion. Tauri, on the other hand is bleeding edge. Meaning you will be bleeding. But it has some advantages that might be worth it.
Here's my evaluation:
- You want to be safe that everything you need is supported (and you have some special needs)? Go with Electron. Over the years they shipped many native systems APIs you can rely on. Tauri is catching up fast, but there might still be some differences.
- You are really worried about security (who wouldn't be)? Both win. Electron has way more cycles gone through. Tauri's overall architecture is better and learned from the mistakes happened in Electron.
- You are worried about the application performance? Go with Tauri. You have less dependencies, no Node, no Chromium, no V8. With Rust you have a foundation for a fast runtime.
FYI: 1Password8 has shown that they shipped their Rust codebase with Electron – the more mature platform. That way you can use both worlds together until Tauri is fully ready. Their solution leverages
Your job at codecentric?
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.