Creating a robust plugin system for your Electron apps - Part 1
Electron is a great framework, that allows you to write desktop applications using the tools that you as a web-developer already know: Javascript, HTML, and CSS. All of our desktop apps are written with Electron, and because of that, we can ship features faster and more reliable, since we don't need to implement a feature multiple times for each operating system.
One feature that I always wanted to implement in one of our apps (Invoker) is a robust plugin architecture. Think about an app like VSCode, which is written with Electron as well, and all the amazing plugins that you can develop and install. If your application is targeted at web developers, using Electron for this task makes even more sense. Your users can use JavaScript, a programming language most of them should be familiar with, to create a custom plugin.
Over the course of the last year, I have implemented a powerful plugin system into one of our applications, and in this series of posts I want to take you behind the scenes and show you how it all works.
Getting Started
There are a lot of things to consider, when you want to start adding plugins to your Electron app. Before you think of any potential features that you want to expose to your plugins, lets start with the basics:
- General architecture
- Detecting a valid plugin for your application
- Loading all available plugins on application start
Plugin Architecture
The overall plugin architecture is split up into two parts. As Electron itself is split up into a background and a main thread, I also create two PluginRepository
classes.
One for the background process and another one for the renderer process.
Those repositories take care of detecting and loading the plugins from the file system, as well as providing the loaded plugins with the correct "context" - so the data that they can access from your application.
The folder structure for our plugins should look something like this at the end:
/My-App/plugins/
/My-App/plugins/my-plugin/
/My-App/plugins/my-plugin/package.json
/My-App/plugins/my-plugin/dist/background.js
/My-App/plugins/my-plugin/dist/renderer.js
So each plugin would consist of one package.json
file that contains metadata, as well as some JS files that will be used for the actual plugin.
This package.json
contains two special entries, which tell our application where it can find the entry points of the plugin for the background process and the renderer process.
This is what such a package.json
file can look like:
{
"version": "1.0.0",
"name": "my-new-app-plugin",
"main": "dist/js/main.js",
"renderer": "dist/js/renderer.js"
}
As you can see, the main
and renderer
entries point to the JS files that contain the plugin code.
Detecting plugins
In order to detect all possible plugins for Invoker, I make the assumption that all of those plugins are stored within a couple of pre-defined global folders.
For this, I use the userData
folder.
This is a directory that is available on a per-user basis, and by default points to:
%APPDATA%
on Windows
$XDG_CONFIG_HOME
or ~/.config
on Linux
~/Library/Application Support
on macOS
You can access this path in your code, using the app.getPath('userData')
method.
The code in my PluginRepository
(for the background process) looks like this:
import { app } from 'electron';
import { join } from 'path';
class PluginRepository {
constructor() {
this.loadedPlugins = [];
}
get pluginPaths() {
const appPath = app.getAppPath();
const userDataPath = app.getPath('userData');
const paths = [
join(userDataPath, "./plugins"),
];
if (process.env.NODE_ENV === 'development') {
paths.push(join(appPath, "./../plugins"));
}
return paths;
}
// ...
}
When initializing the PluginRepository, I am creating an empty array that will later hold all of our loaded plugins.
Then there's a getter for the pluginPaths
property. This returns an array with all paths, that can contain a plugin for my application.
In production, this is only within the user data path, but to make things easier during development, I also add a custom plugin path, which is within the project.
Loading plugins
To actually start loading the plugins from our background process, we need to loop over the pluginPaths
that are configured, and try to find any available plugin.
Here's a simplified version of that method:
loadPluginsInPaths() {
this.pluginPaths.forEach(path => {
const plugins = readdirSync(path);
plugins.forEach(plugin => {
this.loadPlugin(plugin, path);
})
});
}
Using readdirSync
gives us an array containing all folders (and files) that exist within our plugin directory. For each available folder within our plugin directory, we then try to "load" the plugin.
But what does that even mean?
Here's a simplified code snippet to load a plugin:
loadPlugin(plugin, path) {
const pluginPath = join(path, plugin);
const pluginPathPackageJson = join(pluginPath, 'package.json');
if (exists(pluginPath) !== "dir") {
console.warn(`Plugin directory ${pluginPath} does not exist`);
return;
}
if (!exists(pluginPathPackageJson)) {
console.warn(`Plugin package json ${pluginPathPackageJson} does not exist`);
return;
}
let pluginInfo = read(pluginPathPackageJson, "json");
pluginInfo = Object.assign(pluginInfo, {
pluginPath,
renderer: join(pluginPath, pluginInfo.renderer),
main: join(pluginPath, pluginInfo.main),
})
this.requirePlugin(pluginInfo);
}
First of all, we create two variables that hold the actual path of the plugin, as well as the path where we assume to find a package.json
file.
If either of those don't exist, we can not load this plugin and bail out.
If we can find a package.json
within a plugin directory though, we are going to read that file.
Because this package.json
file only contains a relative path to the renderer
and main
entry files, we are going to append the path of the plugin. We also store a reference to the plugin path inside of that modified object.
This will give us an object that looks something like this:
{
"version": "1.0.0",
"name": "my-new-app-plugin",
"pluginPath": "/Users/marcelpociot/Library/Application Support/Invoker/plugins/my-plugin/"
"main": "/Users/marcelpociot/Library/Application Support/Invoker/plugins/my-plugin/dist/js/main.js",
"renderer": "/Users/marcelpociot/Library/Application Support/Invoker/plugins/my-plugin/dist/js/renderer.js"
}
Alright - with this information, we can now try to actually require the main
entry point from within our PluginRepository.
This was pretty tricky in the beginning, as all of my apps use VueJS and Webpack to be bundled - so I could not just require
the file, as this would use Webpacks require
method. Instead, I'm using the __non_webpack_require__
method, which allows me to require any Javascript file from the filesystem.
Our requirePlugin
method looks like this:
requirePlugin(pluginInfo) {
try {
const pluginObject = __non_webpack_require__(pluginInfo.main);
if (pluginObject.default) {
pluginObject.default(this.getPluginContext());
}
this.loadedPlugins.push(pluginInfo);
} catch (err) {
console.error("Error loading plugin " + pluginInfo.main + " :: " + err);
}
}
Using __non_webpack_require__
I'm requiring the pluginInfo.main
file. If this is successful, I'm basically just evaluating the returned plugin function and give it my plugin context.
Here's how an example main.js
file in a plugin would look like:
export default (context) => {
//
}
All that we need is export one function, that receives the plugin context as a parameter.
The getPluginContext
method is very simple and it highly depends on your application what kind of data you want to make accessible from your applications background process.
Everything that you return in here, can be accessed from within the plugin.
Using our new PluginRepository
With the current state of our PluginRepository
, we can already load plugins that can modify the background process of our Electron application.
All that's left to do, is call the PluginRepository.loadPluginsInPaths();
method from within your background process - usually this should happen before the main window gets created, so that plugins can still change any behavior, if that should be possible.
In part 2 of this blog series, we are going to take a look at how we can dynamically load plugins within our renderer process with VueJS.