Skip to content
igor edited this page Oct 26, 2022 · 7 revisions

About Pluggable Electron

Pluggable Electron is a framework to build Electron apps that can be extended by other parties. It uses inversion of control and dependency inversion principles for this. In practice, it provides the tools to add extension points to the code of an Electron app. Plugin developers can then write extensions that can be inserted into these extension points.

This framework includes the tools necessary to manage the whole life cycle of these plugins, e.g. writing, installing, uninstalling and updating plugins, and creating and triggering extension points.

Architecture

test

The framework consists of three main concepts, used to manage the plugin lifecycle:

  • Plugins are the npm packages that include the plugin code
  • Activation points are used to define the points at which plugins can be activated
  • Extension points define the points at which the Electron app can be extended

Plugins are always managed in the main process (see the image above). For now, it is only possible to create extension points on the renderer side. As such, activation points should be managed on the renderer side as well. This is because by ensuring Pluggable Electron only ever executes plugins in the renderer process, the Electron app developer can determine the security level for the plugins by setting up the renderer. There are however future plans to support extension points in the main process as well.

Plugin Lifecycle

In essence plugins are npm packages. They can be installed from any npm registry or from a local tar ball as long as it is supported by npm install. Pluggable Electron allows you to install, uninstall, update, activate and deactivate plugins.

Managing plugins requires the initialisation of Pluggable Electron in the main process, before any browser windows are created. This is done by calling the init function. See the API documentation for the initialisation options. Note that if not all the parameters are known at this point, the usePlugins function can be called at any time to update some parameters.

Although the plugin logic is handled in the main process, a facade can be set up to manage the plugins from the renderer. This facade makes use of Electron's IPC protocol. As such, if the Browser Window is created with nodeIntegration disabled, the facade needs to be initiated in a preload script. If nodeIntegration is enabled, then the facade can be imported directly into the renderer.

Example

// main.js
const pe = require( "pluggable-electron/main" )
...
app.whenReady().then(() => {
  //Initialise pluggable Electron
  pe.init(
    {
      // Function to check from the main process that user wants to install a plugin
      confirmInstall: async plugins => {
        const answer = await dialog.showMessageBox({
          message: `Are you sure you want to install the plugins ${plugins.join(', ')}`,
          buttons: ['Ok', 'Cancel'],
          cancelId: 1,
        })
        return answer.response == 0
      },
      // Path to install plugin to
      pluginsPath: path.join(app.getPath('userData'), 'plugins')
    }
  )
  ...
})
// preload.js
const useFacade = require("pluggable-electron/preload")
useFacade()
// renderer.js
import { plugins } from "pluggable-electron/renderer"

// Get plugin file from input when selected and install
document.getElementById( 'install-file-input' ).addEventListener( 'change', (e) =>
  plugins.install( [e.target.files[0].path] )
)

You can find a complete API definition of the facade here

Activation Points

An activation point is used to define the point in the Electron app's code where plugins should be imported. When an activation point is triggered, a function with a name matching the activation point is called on each plugin that is registered to this activation.

To call this function, the plugin's entry point (main in package.json) should be dynamically imported at the time that the activation point is triggered. However, how this import is done depends on a few factors like which bundler is used, if any, for the main project and the plugins, and which bundle type is generated. For example, most bundlers will try to split the bundle when a dynamic import is used, but in this case the file being imported is not known, resulting in an error. To work around this, the import function needs to be provided using the setup function before plugins can be registered to activation points.

There can be different strategies for activating the plugins, based on e.g. how often plugins are expected to be used and how heavy the expected function are. Examples of activation strategies are:

  • Activate all plugins during startup: One point during the app startup for synchronous extensions and one after startup for async extensions
  • Activate the relevant plugins just before an extension point is triggered.

Registering a plugin with an activation points is done as follows:

  • Define the activation points for the plugin in the plugin's package.json
  • Call Pluggable Electron on startup to register the plugins with their activation point
  • Trigger an activation point to activate all the plugins registered to it

Example

// renderer/index.js
import { setup, plugins, activationPoints } from "pluggable-electron/renderer"

// Enable the activation points
setup({
  // Provide the import function
  importer: async (pluginPath) => import( /* webpackIgnore: true */ pluginPath)
})

// register plugins that have been loaded in the main processwith their activation points
plugins.registerActive()

// Insert this in your code when you are ready to activate the plugins
activationPoints.trigger( 'init' )
// Plugin's package.json
{
  "name": "my-cool-plugin",
  "version": "1.0.0",
  "main": "index.js",
  "activationPoints": [
    "init"
  ]
}
// Plugin's index.js
export const init = (extensionPoints) =>  {
  // Register your extensions here
}

The full API for activations is available here

Defining and Triggering Extension Points

Extension points define the points in the Electron app's code where it is possible to provide extra functionality through a plugin. At these points in the code, an extension point can be triggered after which all extensions registered to it will be executed.

Extension strategies

The expected result of the Extension Point execution is point dependent, but should take one of the following forms:

Handover
  • The plugin is expected to take over the control from the main app and render it's own output. It should be possible for multiple plugins to render their output. In this case the extension should consist of a callback that takes an input from the extension point and does not return anything.
  • An example of this would be a media player. When called, it will render the media that it is passed on screen.
Parallel Execution
  • The plugin is expected to return a value, which will be used by the main app as necessary. The type of the return value will be set by the extension point. This can be returned either directly as the value of the extension or as the result of a callback, possibly taking in an input from the extension point. In the case that multiple extensions are registered to this point, all values will be returned in an array.
  • An example of this would be extending a menu. The extension point will expect each extension to return an object representing the menu entry. The array of objects will be added to the menu and rendered by the main app.
Serial Execution
  • A single value should be processed by multiple extensions sequentially using reduce. In this case the main app will sort the extensions by priority, call the 1st extension with the base value, call the next extension with the result of the 1st plugin as input, and so on. The output of the last extension will then be returned to the main app.
  • An example of this would be processing the price of a purchase item. The 1st extension could convert the price from euros to cents. The second extension could add VAT.

The first two options can be performed using the execute method of the Extension Point. Note that any asynchronous functions in the extensions will be returned as a promise in the result array. If all promises should be resolved before using the array, make sure to await the Promise.all or Promise.allSettled methods on the array.

Extension Point process

In order to execute the appropriate extensions for an extension point, plugins need to register to the extension points. This process starts with activation points, and depends on how activation points have been set up.

  1. If presetEPs has been set to true in the setup, all activation points need to be added using the extensionPoints object before the plugins are registered.
  2. The next step is to register the plugin's extension to the extension point. This is done in the activation function of the plugin. How this is done depends on the presetEPS value.
    • If presetEPs is true, the activation function is passed an object with all extension points as defined in step 1.
    • If presetEPs is false, the activation function is passed functions to register to extension points and to execute execution points.
  3. With the extensions registered, the extension point can be called anywhere in the electron app where the possibility to extend is required, using the strategies defined above.

Examples

presetEPS set to true

// renderer/index.js
import { setup, extensionPoints, activationPoints } from "pluggable-electron/renderer"

// Enable the activation points
setup({
  // Provide the import function
  importer: async (pluginPath) => import( /* webpackIgnore: true */ pluginPath),
  // Define that Extension Points should be defined in the main app.
  presetEPs: true
})

// Add extension points.
extensionPoints.add( 'purchase_menu' )
extensionPoints.add( 'basket_item_getPrice' )

// Insert this in your code when you are ready to activate the plugins
activationPoints.trigger( 'init' )
// plugin's index.js
// Triggered by the init activation
function init( extensionPoints ) {
  // Mock function for adding a menu item
  const toggleVatResponse = totalPrice => {
    if ( totalPrice > 0 ) return {
      label: 'Toggle VAT total',
      position: 20,
      action: () => { /* do something */ }
    }
  }

  // Register to purchase_menu extension point
  extensionPoints.purchase_menu.register( 'Toggle VAT', toggleVatResponse )

  // Mock function for adding VAT to price
  const addVat = price => price * 1.15

  // Register to basket_item_getPrice extension point
  extensionPoints.basket_item_getPrice.register( 'add VAT', addVat, 10 )
}
// Somewhere in the main app code, e.g. basket.js
import { extensionPoints } from "pluggable-electron/main"

// ... Your business logic ...

// Call the extension point when needed. E.g,
let purchaseMenu = [
  { label: 'Check out', position: 1, action: /* Yet another callback */}
  { label: 'Empty', position: 2, action: /* You get the point */}
  // Etc.
}]

const extendMenu = extensionPoints.execute('purchase_menu', basket.totalPrice )
purchaseMenu = purchaseMenu.concat( extendMenu )

// ... Some more business logic ...
// Until another point is reached where the app can be extended.
const finalPrice = extensionPoints.executeSerial( 'basket_item_getPrice', basket.item[i].price )

presetEPS set to false

// renderer/index.js
import { setup, activationPoints } from "pluggable-electron/renderer"

// Enable the activation points
setup({
  // Provide the import function
  importer: async (pluginPath) => import( /* webpackIgnore: true */ pluginPath)
  // False is the default for presetEPs
})

// Adding extension points separately is not needed

// Insert this in your code when you are ready to activate the plugins
activationPoints.trigger( 'init' )
// plugin's index.js
// Triggered by the init activation
function init( extensionPoints ) {
  // Mock function for adding a menu item
  const toggleVatResponse = totalPrice => {
    if ( totalPrice > 0 ) return {
      label: 'Toggle VAT total',
      position: 20,
      action: () => { /* do something */ }
    }
  }

  // Register to purchase_menu extension point
  extensionPoints.register( 'purchase_menu', 'Toggle VAT', toggleVatResponse )

  // Mock function for adding VAT to price
  const addVat = price => price * 1.15

  // Register to basket_item_getPrice extension point
  extensionPoints.register( 'basket_item_getPrice', 'add VAT', addVat, 10 )
}

Executing the extension points is then the same as in the previous example.

The full API for extension points is available here.

Demo project

A demo project using Pluggable Electron can be found here: https://github.com/dutchigor/pluggable-electron-demo. Also check out the with-vue branch to see an example with Vite and Vue. This example contains a few catches to be aware of when using packaged frontend framework so I recommend checking this out for any such framework.