Expose Logo

Expose — Share local sites

Go back to Blog

Plugin Architecture for Electron apps - Part 1

Marcel Pociot

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.

Build desktop applications as a web developer

Our course teaches you how to build, publish, and distribute desktop applications with HTML, JavaScript and CSS.

Learn more
Desktop Apps With Electron course