Plugin API
This page describes the API to extend Drovp available in 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: 32.1.2
#Plugin
An object with plugin API.
interface Plugin {
registerProcessor<Payload, Dependencies>(name: string, config: ProcessorConfig): void;
registerDependency(name: string, config: DependencyConfig): void;
}
#registerProcessor()
registerProcessor<Payload, Dependencies>(name: string, config: ProcessorConfig): void;
Registers the actual module that will process operations.
When using TypeScript, optionally pass your Payload
and Dependencies
if any into generic parameters so the processor's config types (such as operationPreparator
utils parameter) are typed correctly.
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, meta: {modifiers: string});
expandDirectory?: (item: ItemDirectory, options: Options, meta: {modifiers: string}) => 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.
Type | Description |
---|---|
files | Any filesystem files dragged into the profile. |
directories | Any filesystem directories dragged into the profile. |
blobs | Binary blobs such as images pasted into the profile. |
urls | Any URLs pasted or dragged into the profile. |
strings | Any 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:
Type | Comparison target |
---|---|
file | Extension and basename. |
directory | Basename. |
blob | Mime type. |
url | Start of the domain+pathname parts of the domain:`example.com/path/name`.startsWith(flag) . |
string | String 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:
text/plain
- the string you selectedtext/html
- HTML formatted version of the stringvscode-editor-data
- a JSON with editor selection data
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, meta: {modifiers: string}) => 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, meta) => options.bulk,
meta.modifiers
is a string with modifiers
pressed when dropping items into the profile.
#expandDirectory
Type: (item: ItemDirectory, options: Options, meta: {modifiers: string}) => 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, meta) => options.expandDirectories,
meta.modifiers
is a string with modifiers
pressed when dropping items into the profile.
#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:
Type | Description |
---|---|
cpu | CPU heavy. |
gpu | GPU heavy. |
download | Network download line. |
upload | Network upload line. |
io | Any 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:
percentage
- default formatter that displays a percentage if bothcompleted
andtotal
progress values are available.bytes
- will formatcompleted
progress value into human readable number of bytes (1100
>1.1 KB
).
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) {
// Delete anything at path and create an empty directory there
await utils.prepareEmptyDirectory(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;
pluginDataPath: string;
}
dataPath
A path to where the dependency stored any files it downloaded or generated during installation.
pluginDataPath
A path where plugin can save it's session data that should persist. Intended for config files, credentials, logs, history, and such.
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;
pluginDataPath: string;
extract: Extract;
download: Download;
prepareEmptyDirectory: (path: string) => Promise<void>;
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
.
pluginDataPath
A path where plugin can save it's session data that should persist. Intended for config files, credentials, logs, history, and such.
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
.
prepareEmptyDirectory(path: string): Promise<void>
Deletes anything at path
and creates an empty directory in its stead recursively.
Has a special handling to prevent weird issues on windows where trying to recursively delete and recreate a directory when its parent is open in explorer would fail for some reason.
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',
});