Plugin API

This page describes the API to extend Drovp available to the package.json:main plugin file.

Module

Plugin is a module with one default export, which is a synchronous function that accepts Plugin interface as its 1st argument.

module.exports = (plugin: Plugin) => {
    plugin.registerProcessor(name, config);
    plugin.registerDependency(name, config);
};

Everything inside this file runs in Electron's renderer process.

Current Electron version: 15.3.3

Plugin

An object with plugin API.

interface Plugin {
    registerProcessor(name: string, config: ProcessorConfig): void;
    registerDependency(name: string, config: DependencyConfig): void;
}

registerProcessor()

registerProcessor(name: string, config: ProcessorConfig): void;

Registers the actual module that will process operations.

name

Type: string

A string identifying processor within the plugin namespace. It'll be used to generate processor ID using the template {plugin_name}:{processor_name}.

If plugin provides only one processor, convention is to give it the same name as plugin, without the drovp- prefix. For example, if plugin drovp-foo provides a single processor, it should name it foo. It's ID would than be drovp-foo:foo.

Processor and dependency ID's can have same names as they exist in separate namespaces.

Currently, the only place users or developers can come in contact with this ID is in profile import codes.

config

Type: ProcessorConfig

See ProcessorConfig.

Quick example

plugin.registerProcessor('foo', {
    main: 'processor.js',
    accepts: {files: true},
    options: {verbose: false},
    threadType: 'cpu',
});

registerDependency()

registerDependency(name: string, config: DependencyConfig): void;

Registers a processor dependency. Processor dependency is anything that needs to be setup, downloaded, extracted, or configured before processor can do its thing. This is an extra interface for dependencies and setups that are not or can't be just npm installed. In case your plugin depends on something simple that is available on npm, just npm install it as your plugin's package.json dependency.

Don't depend on native modules that require compilation, as it's not practical or realistic to expect Drovp users to setup compilation environments on their machines just to use Drovp.

Register dependencies before plugins that require them.

name

Type: string

A string identifying dependency within the plugin namespace. It'll be used to generate dependency ID using the template {plugin_name}:{dependency_name}.

This ID can then be used by processors to declare that they depend on it.

If plugin provides only one dependency, convention is to give it the same name as plugin, without the drovp- prefix. For example, if plugin drovp-foo provides a single dependency, it should name it foo. It's ID would than be drovp-foo:foo.

config

Type: DependencyConfig

See DependencyConfig.

Quick example

plugin.registerDependency('foo', {
    load: async (utils) => await loadDependency(),
    install: async (utils) => await installDependency(),
    instructions: 'path/to/instructions.md',
});

ProcessorConfig

An object with processor configuration.

interface ProcessorConfig {
    main: string;
    description?: string;
    instructions?: string;
    dependencies?: string[];
    optionalDependencies?: string[];
    accepts?: {
        files?: boolean | string | FileFilter | RegExp | (string | FileFilter | RegExp)[];
        directories?: boolean | string | DirectoryFilter | RegExp | (string | DirectoryFilter | RegExp)[];
        blobs?: boolean | string | BlobFilter | (string | BlobFilter)[];
        strings?: boolean | string | StringFilter | RegExp | (string | StringFilter | RegExp)[];
        urls?: boolean | string | UrlFilter | RegExp | (string | UrlFilter | RegExp)[];
    };
    bulk?: boolean | ((inputs: Item[], options: Options) => boolean);
    expandDirectory?: (item: ItemDirectory, options: Options) => boolean;
    threadType?: string | string[] | ((operation: Payload) => string | string[]);
    threadTypeDescription?: string;
    options?: OptionsSchema | OptionsLaxSchema;
    parallelize?: boolean | ((operation: Payload) => boolean);
    keepAlive?: boolean;
    dropFilter?: (items: Item[], options: Options) => Item[] | Promise<Item[]>;
    operationPreparator?: (operation: Payload, utils: PreparatorUtils) => Promise<Payload | null>;
    modifierDescriptions?: {[key: string]: string} | ((options: Payload['options']) => {[key: string]: string});
    progressFormatter?: 'bytes' | ((progress: ProgressData) => string);
}

interface ItemFile {
    kind: 'file';
    type: string; // lowercase extension type without the dot
    path: string;
    size: number;
}
interface ItemDirectory {
    kind: 'directory';
    path: string;
}
interface ItemBlob {
    kind: 'blob';
    mime: string;
    contents: Buffer;
}
interface ItemString {
    kind: 'string';
    type: string;
    contents: string;
}
interface ItemUrl {
    kind: 'url';
    url: string;
}

type Item = ItemFile | ItemDirectory | ItemBlob | ItemString | ItemUrl;
type Options = {[key: string]: any} | undefined;
type FileFilter = (item: ItemFile, options: Options) => boolean;
type DirectoryFilter = (item: ItemDirectory, options: Options) => boolean;
type BlobFilter = (item: ItemBlob, options: Options) => boolean;
type StringFilter = (item: ItemString, options: Options) => boolean;
type UrlFilter = (item: ItemUrl, options: Options) => boolean;
type ProgressData = {completed?: number; total?: number};

interface Payload {
    readonly id: string;
    options?: {[key: string]: any};
    inputs?: Item[];
    input?: Item;
}

main

Type: string

Path to processor module file relative to plugin directory root.


description

Type: string optional

Short description of what the processor does.


instructions

Type: string optional

Optional instructions on how to use the processor.

It can either be a markdown supported string, or a path to a markdown file relative from plugin's root directory. It'll be recognized as a path if it ends with .md.

If your plugin only extends the app with one processor, chances are the instructions are already in its readme, so you can just link to that.

Example

plugin.registerProcessor('foo', {
    ...,
    instructions: 'README.md',
});

dependencies

Type: string[] optional

An array of Drovp plugin dependency IDs this processor relies on. When declaring external dependencies (dependencies provided by other plugins), they'll be installed along with the plugin that ships them.

You can see official dependencies in Helper modules page. To see all available drovp dependencies, search drovpdependency keyword on npmjs.com.

Example
dependencies: ['@drovp/ffmpeg:ffmpeg', '@drovp/ffmpeg:ffprobe'],

Plugin name in dependency identifier is optional for dependencies that are provided by the same plugin.

dependencies: ['currentPluginDependencyName'],

If dependencies provide some payloads, they'll be available inside processor on the utils parameter:

// processor.js
module.exports = async (payload, utils) => {
    console.log(utils.dependencies.ffmpeg); // path to ffmpeg binary
    // or a more explicit no-conflict risking alternative
    console.log(utils.dependencies['@drovp/ffmpeg:ffmpeg']); // path to ffmpeg binary
};

optionalDependencies

Type: string[] optional

Same as dependencies above, but processors won't be prevented from processing operations if any of these dependencies is missing by failing to install or load.


accepts

Type: Record<AcceptsType, AcceptsFlags> optional

An object that tells Drovp what kind of input items this processor accepts. For exact API, see the definition above.

If an item dropped into the profile for given processor doesn't make it through the accepts flags filter, it'll simply be ignored and never sent to the processor.

TypeDescription
filesAny filesystem files dragged into the profile.
directoriesAny filesystem directories dragged into the profile.
blobsBinary blobs such as images pasted into the profile.
urlsAny URLs pasted or dragged into the profile.
stringsAny strings pasted or dragged into the profile.
strings also receive URLs, as they are also strings.

If accepts flag is a string, here is a table of what it'll be compared against for each item type:

TypeComparison target
fileExtension and basename.
directoryBasename.
blobMime type.
urlStart of the domain+pathname parts of the domain:
`example.com/path/name`.startsWith(flag).
stringString type (e.g. text/plain).
Strings note

If you want to accept strings, in most cases you'd want to set the value to text/plain instead of a blank true. This is because string paste events usually contain multiple items, most of which you probably don't care about.

For example, if you copy some text from vscode and paste it into a profile, it'll receive 3 item types:

Setting strings: true in this case would create 3 operations per each item in the paste event.

Examples
accepts: {
    files: true,  // Accept all files
    files: 'jpg', // Only jpg files
    files: /readme(\.md)?/, // Only `readme` & `readme.md`
    // Only items that match the function check.
    // `options` are profile options configured by the user
    // matching the processor's OptionsSchema.
    files: (item, options) => options.extensions.includes(item.type),
    files: [/jpe?g/, 'gif', 'png', 'webm'], // An array of string/RegExp/function flags
    string: 'text/plain' // Only plain strings
}

bulk

Type: boolean | (inputs: Item[], options: Options) => boolean optional
Default: false

Wether all of the items dropped into the profile should be bulked into one operation, or split each into a separate one.

You can also pass a function that will determine wether to bulk dynamically on drop by drop basis:

// Use user's profile options to determine bulking.
// Requires processor's OptionsSchema to define a boolean `bulk` option.
bulk: (items, options) => options.bulk,

expandDirectory

Type: (item: ItemDirectory, options: Options) => boolean optional

If processor accepts both files and directories, this deciding function can be used to force the app to optionally expand a directory (use recursively all files inside the directory as inputs instead of the directory itself) when needed.

By default, Drovp expands all directories unless processor configures accepts.directories flags.

// Use user's profile options to determine directory expansion.
// Requires processor's OptionsSchema to define a boolean `expandDirectories` option.
expandDirectory: (item, options) => options.expandDirectories,

threadType

Type: string | string[] | ((operation: Payload) => string | string[]) optional
Default: 'undefined'

Drovp has a powerful queue and parallelization logic which limits the max number of operations of the same thread type that can run at the same time to whatever the user has configured.

Although thread type can be any string, or an array of strings, here are keywords that should be used for the most common thread types:

TypeDescription
cpuCPU heavy.
gpuGPU heavy.
downloadNetwork download line.
uploadNetwork upload line.
ioAny local storage IO, (reading/writing/renaming files and folders).

Thread type should describe the load type that is the given processor's bottleneck. For example, if the purpose of your processor is to upload dropped files somewhere, you are doing io (reading the file) as well as upload, but the io is hardly the bottleneck here, so your thread type should just be upload.

If you don't specify a thread type, your processor will share the undefined thread pool with all other uncategorized operations, which will make it impossible to parallelize its operations as efficiently as possible.

You can also use custom thread types. For example, uploading to a service example.com might be slow, and won't saturate anyone's upload bandwidth, but you don't want to DDOS the example.com either, in which case you might fragment the thread type to upload:example.com, and disable parallelize processor config to force only a single thread of this type to run at any given time. In this case people can start other upload operations while your processor is slowly uploading to example.com.

In case your processor is bottlenecked by multiple load types, you can pass them all in an array.

Thread type can be determined dynamically for each operation by passing a determiner function, which accepts a Payload, and returns a thread type string, or an array of those:

// Use user's profile options to determine thread type.
// Requires processor's OptionsSchema to define a string `uploadServiceDomain` option.
threadType: (operation) => `upload:${operation.options.uploadServiceDomain}`,

threadTypeDescription

Type: string optional

In case of unconventional thread types, inform the user what is going on. This is displayed on processor's Details page.


options

Type: OptionsLaxSchema | OptionsSchema optional

Options schema used to define profile options and construct the options UI for the user.

When item is dropped into a profile, it and the profile options are serialized, attached to the operation payload, and sent to the processor.

If you are just creating a quick and dirty script for personal use, you can just pass a dumb object with option names and their default values:

{
    foo: false,
    bar: 'string',
    baz: 8,
}

Drovp will than look at the types of values and display a most appropriate UI to control them.

But in most cases, and especially if you plan on publishing the plugin to registry, you should define a proper OptionsSchema where you can give your options titles, descriptions, hints, and create categories, namespaces, collections, hiding options, etc.

The above example in a proper options schema would look like this:

[
    {
        name: 'foo',
        type: 'boolean',
        default: false,
        title: 'Foo',
        description: 'This is a checkbox that enables baz.',
    },
    {
        name: 'bar',
        type: 'string',
        default: 'string',
        rows: 3,
        title: 'Bar',
        description: 'Setting rows option higher than 1 makes this a textarea.',
        hint: (value) => `${value.length} chars`,
    },
    {
        name: 'baz',
        type: 'number',
        default: 8,
        min: 1,
        max: 10,
        title: 'Baz',
        description: 'Baz is a slider that is hidden unless foo is enabled.',
        isHidden: (value, options) => !options.foo,
    },
];

parallelize

Type: boolean | ((payload: Payload) => boolean) optional
Default: true

When false, Drovp will not spin more than one thread of a given operation's thread type.

It can also be determined dynamically for each operation by passing a determiner function.


keepAlive

Type: boolean optional

When true, Drovp will always keep at least 1 thread of this processor alive.

By default, all processor threads are cleaned up after some time of inactivity. This might be undesirable when processor needs to do some time consuming setup each time its thread is created (authenticating to endpoints, loading stuff, etc.).


dropFilter

Type: (items: Item[], options: OptionsParam) => Item[] | Promise<Item[]> optional

A function to filter out items dropped into the profile before Drovp starts filtering using accepts flags, and expanding folders into files.


operationPreparator

(payload: Payload, utils: PreparatorUtils) => Promise<Payload | null>

optional

An async function that will be called before each operation is created to do anything it wants to its payload. This can be used to display a file save dialogue, or in advanced cases spawn a whole new window with editing UI (cut videos, crop images, ...).

payload

Type: Payload

{id: string, options: Options, items: Item[], item: Item}

utils

Type: PreparatorUtils

Utilities that might be useful here. See PreparatorUtils.

Return

Preparator can resolve with the same or modified payload, or any falsy value if the operation should NOT proceed.

When modifying the payload, it has to stay an object with unmodified id property.
Also, don't touch the input property. It's just a convenience getter for the first item in the inputs array.

Apart of that, you can modify the payload in any way you want, as you are just preparing it to be consumed by your own processor. The payload data can be inspected on each operation's page by clicking the Payload tab.

Example

Preparator that adds ctrl modifier that prompts the user for destination directory, and adds it to the payload:

const Path = require('path');

async function operationPreparator(payload, utils) {
    if (utils.modifiers === 'ctrl') {
        const result = await utils.showOpenDialog({
            title: `Destination directory`,
            defaultPath: Path.dirname(payload.item.path),
            properties: ['openDirectory', 'createDirectory', 'promptToCreate'],
        });

        // Cancel operation
        if (result.canceled) return false;

        const dirname = result.filePaths[0];

        if (typeof dirname === 'string') {
            payload.options.destination = dirname;
        } else {
            throw new Error(`No directory selected.`);
        }
    }

    return payload;
}

NOTE: When adding special modifier behavior, you should use modifierDescriptions option below to document them.


modifierDescriptions

Type: {[key: string]: string} | ((options: Payload['options']) => {[key: string]: string}) optional

If your operation preparator uses modifiers to change it's behavior, you should document them here. This will be shown in each profile's options section under the modifiers help button at the top.

It is als a good idea to document these in either processor's instructions, or plugin readme.

Example
plugin.registerProcessor('foo', {
    // ...,
    modifierDescriptions: {
        alt: `alternative behavior`,
        'alt+shift': `even more alternative behavior`,
    },
});

progressFormatter

Type: 'bytes' | ((progress: ProgressData) => string) optional

If your processor reports progress, you can use this to specify how the progress should be formatted and displayed to the user.

If your processor reports both completed and total progress values, operation progress UI will display a percentage automatically.

But some operations don't have access to the total value beforehand, in which case Drovp won't display anything, just style the progress as indeterminate.

In this case, you can still report the completed value (whatever that is), and use this config to set up a progress formatter, which can be a name of a pre-defined one, or function with custom formatter.

By default, these formatters are available:

Example

Formats completed value into number of processed items.

const formatProgress = ({completed}) => `${completed} items`;

operationMetaFormatter

Type: (meta: any) => string optional

Work in progress, documented for completeness, doesn't do anything at the moment.


profileMetaUpdater

Type: (profileMeta: any, operationMeta: any) => any optional

Work in progress, documented for completeness, doesn't do anything at the moment.


profileMetaFormatter

Type: (meta: any) => string optional

Work in progress, documented for completeness, doesn't do anything at the moment.

DependencyConfig

An object with dependency configuration.

interface DependencyConfig {
    load(utils: LoadUtils): Promise<boolean | DependencyData>;
    install?(utils: InstallUtils): Promise<void>;
    instructions?: string;
}

interface DependencyData {
    version?: string;
    payload?: any;
}
Example

A dependency that downloads a binary and exposes its path to processors:

const Path = require('path');
const FSP = require('fs').promises;
const exec = require('util').promisify(require('child_process').exec);

const EXECUTABLE_NAME = process.platform === 'win32' ? 'deno.exe' : 'deno';
const ARCHIVE_NAME = {
    win32: 'deno-x86_64-pc-windows-msvc.zip',
    linux: 'deno-x86_64-unknown-linux-gnu.zip',
    darwin: 'deno-x86_64-apple-darwin.zip',
}[process.platform];
const DOWNLOAD_URL_BASE = 'https://github.com/denoland/deno/releases/latest/download/';

async function load(utils) {
    const executablePath = Path.join(utils.dataPath, EXECUTABLE_NAME);
    const {stdout} = await exec(`"${executablePath}" --version`);
    return {version: stdout, payload: executablePath};
}

async function install(utils) {
    // Cleanup previous files
    await utils.cleanup(utils.dataPath);

    // Download latest
    utils.stage('downloading');
    const archiveFilename = await utils.download(`${DOWNLOAD_URL_BASE}${ARCHIVE_NAME}`, utils.dataPath, {
        onProgress: utils.progress,
    });
    const archivePath = Path.join(utils.dataPath, archiveFilename);

    // Extract the archive
    utils.stage('extracting');
    utils.progress(null); // Reset progress to indeterminate
    const files = await utils.extract(archivePath); // Same directory extract

    // Ensure we extracted what we needed
    if (!files.includes(EXECUTABLE_NAME)) {
        throw new Error(`Unexpected archive contents.`);
    }

    // Delete the archive
    await FSP.rm(archivePath);
}

// Create plugin
module.exports = (plugin) => {
    // Register dependency
    plugin.registerDependency('deno', {load, install});
};

load()

load(utils: LoadUtils): Promise<boolean | DependencyData>;

An async function whose purpose is to check if dependency is installed and resolve with true if ready, or a dependency data consisting of version and/or payload for processor's to consume, if any. See above for example.

utils

interface LoadUtils {
    dataPath: string;
}
utils.dataPath

A path to where the dependency stored any files it downloaded or generated during installation.

Return

A promise that resolves with true or DependencyData if dependency is satisfied, and false or simply throws an error with message of what is wrong otherwise.

When returning DependencyData, payload has to be a serializable value as it's going to be transmitted to the processor through an IPC channel.


install()

install?(utils: InstallUtils): Promise<void>;

An async function whose purpose is to install the dependency. This is not required, as in some situations it might be unrealistic to do everything necessary in a script. In such case you should provide instructions on how the user should install the dependency themselves. See above for example.

utils

interface InstallUtils {
    dataPath: string;
    tmpPath: string;
    extract: Extract;
    download: Download;
    cleanup: Cleanup;
    progress: Progress;
    stage: (name: string) => void;
    log: (...args: any[]) => void;

    // CommonModals
    alert(data: ModalData): Promise<void>;
    confirm(data: ModalData): Promise<ModalResult<boolean>>;
    prompt(data: ModalData, stringOptions?: OptionString): Promise<ModalResult<string>>;
    promptOptions<T extends OptionsData | undefined = undefined>(
        data: ModalData,
        schema: OptionsSchema<T>
    ): Promise<ModalResult<T>>;
    showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogReturnValue>;
    showSaveDialog(options: SaveDialogOptions): Promise<SaveDialogReturnValue>;
    openModalWindow<T = unknown>(options: string | OpenWindowOptions, payload: unknown): Promise<T>;
}
dataPath

A path to where the dependency should store any files it downloaded or generated during installation.

Location of this path is {AppData}/dependencies/{pluginName}, and it's shared with all dependencies of the same plugin. You can see the exact path to dependencies directory on your system in app's About section.

tmpPath

If the installation needs to store some temporary data, it should do so in this path, which will be created clean before the installation, and deleted right afterwards.

The location of tmpPath directory is simply {dataPath}-tmp.

extract

Type: Extract

An archive extraction utility. See Extract.

download

Type: Download

A file download utility that follows redirects. See Download.

progress

Type: Progress

A progress reporting utility. See Progress.

cleanup

Type: Cleanup

A simple path cleanup utility. See Cleanup.

stage(name: string) => void

Used to inform the user about and log the name of the current installation stage. This is a completely optional way how to make reading through logs during debugging more informative.

Side effect: Setting new stage resets progress to {completed: 0, total: undefined}.

log(...args: any[]) => void

Log something to the installation staging log. If something goes wrong, you can inspect it in Events > Stagings section.

modals

InstallUtils have also access to CommonModals, which are documented on Util APIs page. Example:

await utils.alert({title: 'Foo', message: `Foo happened because of bar.`});

Return

There is no expected return value. The installation should simply do its thing and resolve.

In case something happens, you should throw with a message describing the issue.


instructions

Optional instructions on how to install and/or manage the dependency.

It can either be a markdown supported string, or a path to a markdown file relative from plugin's root directory. It'll be recognized as a path if it ends with .md.

Example

plugin.registerDependency('foo', {
    check: checkFoo,
    instructions: 'foo_instructions.md',
});