改善
Kaizen  · Today I Learned by Ville Säävuori

Initial Learnings With Electron

The following are my learnings from the first proper Electron project building the Slipmat Soundboard app. The app is open source, and like all Slipmat projects, it’s geared towards DJs and artists.

Slipmat Soundboard

Storing Electron state and preferences

This was the very first thing I wanted to figure out as I wasn’t sure which would be the best way to do this. There are a few libraries for saving window positions and other state in various ways, I opted for a combo of electron-store for the main process and Pinia in the renderer.

Electron-store uses plain json files to store any data you like and has some nice functionality like versioning and schema support for the data. It was very easy to work with.

For the renderer I decided to use Pinia as it’s the next generation official state management (ie. better typed Vuex for Vue 3) for Vue.

Making Electron feel native

As a macOS user I’m pretty mindful about the overall look and feel of any native apps. (I also understand that there are different conventions for other platforms but as I don’t have access to those myself and this is just a hobby project, they are outside of the initial scope.) I found a great blog post discussing this very topic and implemented some of the things for the first MVP.

There are basically two paths to great usability in a native app; either you make it look and feel really native all the way or you make it look customized but conform to native conventions as much as possible. As we’re working with Web technologies and planning to support multiple platforms the second option felt much more doable. Also, audio apps often have a bit more soul to them so designing a custom interface felt right.

Loading and opening windows

Opening a native app window is a pretty straightforward process. Opening an Electron window, however, creates and starts a whole Chrome process which loads and renderes your Web app so it’s a vastly different process. If you don’t optimize this process it’s likely that launching your app is anything but a smooth experience.

There’s basically nothing you can easily do to make the basic process any leaner, Electron just needs to do what it needs to do. So if your app needs to load lots of data or a complex app, you’ll probably want to launch a separate splash/loading screen first before showing the main window. I actually built this already once for my Electron+Python template (which I’ll hopefully get cleaned up and published soon) but it seemed overkill here.

I opted to build the renderer app initializer in a way that will ping the main process after it’s loaded and ready so I can create the window hidden, wait for it to load (which takes about 50-200ms), and only after it’s loaded and fully rendered call the main process to actually show the main window. The delay isn’t long enough to feel slow but this totally avoids any flashing or wonkiness of the initial opening of the app.

When the app grows I expect to use the typical Web app state loading tricks of caching some of the most important data (cache+rehydrate), showing placeholders for non-critical things while loading, etc. For the moment it seems that even without any optimizations the app loads fairly quickly even on a 2015 Macbook Pro so I’m not expecting any major hurdles here anytime soon. There’s a great talk by Johannes Rieken titled Visual Studio Code - The First Second that goes pretty deep into how VSCode team makes the app startup performant.

Focused vs unfocused state

This is a pretty big one and also easy to implement. Here’s the basic plumbing.

In main process:

mainWindow.on('blur', () => {
  mainWindow.webContents.send('blur')
})

mainWindow.on('focus', () => {
  mainWindow.webContents.send('focus')
})

In renderer:

window.api.receive('blur', () => {
  store.changeAppFocus(false)
})

window.api.receive('focus', () => {
  store.changeAppFocus(true)
})

Draggable areas and text selection

Another small big thing that often seems to be forgotten is the handles for dragging the window and text selection in the main UI. Native apps aren’t supposed to support generic “select any text from the interface by clicking and dragging with mouse” action and if you leave the default browser behavior on, it just feels very wrong.

Also on macOS windows are dragged from the titlebar so if you customize it you need to make sure to add that functionality back in.

Luckily both of these are very simple CSS fixes which I added in the main renderer app and main styles.

Other things

There’s just too much stuff here to cram into MVP project but I did at least figure out how to do everything to make implementation easy when I get back to it. Here’s some of them.

Slipmat Soundboard

Using system fonts and font sizes. Tailwind makes this trivial but I didn’t commit to his fully yet as almost everything in the UI kept changing throughout the initial sprint. I did set the base font-size to 14px which made a bigg difference in overall feel.

Styling scroll bars. Scroll bars are a huge PITA and usability problem in the latest macOS versions. Turns out they also look wonky with the dafault settings in Chrome so I made them less weird with some basic styling.

Saving/restoring window sise and position. This is a big one I didn’t have time to implement yet. There’s a package for this but it’s also not too hard to save the state manually using the same state handling I already have in place.

Setting window background color. Again very small and easy thing to get right so that resizing the window won’t result in flashes of different background color.

Collecting telemetry data. This one might be controversial but I’m personally a big believer in measuring everything. There are also ethical ways to do this so I don’t see any problem with it, especially when you give the user a choice to opt out if they want to. (I feel like opt in here would basically say that “we feel this is so dirty that we need you to give us permission”. For me it’s more like “hey, this anonymous data is useful for us and collecting it does not compromise your privacy in any way. But, if you don’t like it, you can opt out”.) I tested this out for a proof-of-concept and it looks like I can easily use Plausible to collect basic usage telemetry (for this app it would be super helpful to know 1) amount of samples users have, and 2) what screen resolutions they use) so I’ll probably add it to the first 0.x version (the MVP was tagged v0.0.1).

Securing Electron apps

Security is always one of my top priorities in every single project no matter how big or small. Electron has a notoriously bad security history but I’ve been happy to learn that current versions (v17) take security seriously and the documentation around security is already pretty good and comprehensive (to the point that it’s way, way too much information for a beginner to take all in in one reading).

The basics

To understand the whole picture of Electron security one needs to understand the basic Electron architecture which condenced in one sentence is that it’s split into three parts; main (the visible window, web app), renderer (the node process that controls everything), and prerenderer (that sits between main and renderer).

Most Electron security issues in the past have been due to the fact that the renderer process has been given full access to the full Node API that the main process hass access to. So being able to execute JavaScript in the renderer has meant basically full unrestricted access (within the OS limits of cource) to everything the user has access to. This is obviously terrifying and made me cry a little bit inside when I realized how bad it actually was with the early Electron apps that I myself have used plenty. But, things have changed a lot.

Nowadays the default settings for a new renderer process starts it with nodeIntegration: false. This disables the node integration totally so renderer has the same kind of very limited access to the system that any Web browser does. This is good for security but obviously makes using Electron APIs harder.

The new way to use Node APIs in the renderer is to use contextBridge. This is a “safe, bi-directional, synchronous bridge across isolated contexts”. It means writing your own handlers in preload process to talk back and forth between the main and renderer process which you then expose to the renderer. This is a much safer but not trouble-free solution as it once again gives you a loaded gun and just expects you to not shoot yourself in a foot with it. It’s arguably faster to do this wrong (by exposing too much or just passing through full node APIs) than to do it right so you need to be extra careful and diciplined here.

The current documentation is comprehensive but not great all around. It has plenty of inconsistencies regarding the new and the old APIs, and I myself got a clear picture of the current best practices after asking about it on the Electrin discord server (which to their credit worket great and I got some excellent advice there).

Some specific security tips

One thing I haven’t yet found an easy solution is how to disable --inspect and --inspect-brk flags from the final production build. I don’t see any reason to have these things in a final production binary but for some reason there doesn’t seem to be an easy way to do this.

In Conclusion

My first MVP project with Electron was pretty fun. The patform is mature and stable, the tooling is fantastic, and there are plenty of documentation and examples out there to get almost everything done.

My experience with the Electron discord server was also positive. I got to speak to a couple of human beings who were helpful and provided really good answers to my questions.

I do hope that projects like Tauri (which I’ve tested before) mature enough to give Electron some proper competition, but after this MVP I’m very happy that I didn’t start with Tauri as this project most likely would not have succeeded with it (because of the limited time window and usage of non-typical APIs like WebMidi).