Plugin runtime
How a plugin is written, loaded, and run.
A plugin is an ES module whose default export is definePlugin(…) from
@vimputer/plugin-sdk. It runs in Electron main, trusted, with no sandbox. The
host owns persistence, the picker, and how actions are drawn; the plugin supplies
data and code.
import { z } from 'zod'
import { defineAction, definePlugin } from '@vimputer/plugin-sdk'
const run = defineAction({
id: 'run',
label: 'Example command',
paramsSchema: z.object({ url: z.string() }),
async run(ctx, { url }) {
await ctx.openUrl(url)
},
// Pure: recognize an imported action as this one, or pass.
claim: (action) => (action.kind === 'open_url' && action.url.startsWith('example://') ? { url: action.url } : null),
})
export default definePlugin({
id: 'example',
label: 'Example',
actions: [run],
async discover(ctx) {
return { groups: [{ id: 'all', label: 'Commands', items: [/* { id, title, actionId: 'run', actionParams } */] }] }
},
})
What a plugin can export
| Export | Does |
|---|---|
actions |
Code that runs when a bound key fires. paramsSchema validates the binding’s params. |
discover(ctx) |
The external inventory as groups of items, each naming an action and its params. |
claim on an action |
Rewrites a matching imported action into this plugin’s action. Must be pure. |
views |
Pages and author dialogs, written in React against ten host elements. |
activate(ctx) |
A long-lived service, such as a socket. Returns { dispose() }. |
Discovery is stale-while-revalidate: the last snapshot, persisted to disk, is served
at once and refreshed in the background. Saving a plugin instance, or a service
calling invalidateDiscovery(), forces a fresh scan.
The action context
Every run, discover, and service gets a ctx:
| Member | For |
|---|---|
exec(file, args) |
Run a binary with argv, never through a shell |
fetch, openUrl, notify |
HTTP, URLs, notifications |
instances |
Plugin-owned records, such as Soundboard’s boards |
secrets |
Passwords and keys, encrypted with safeStorage |
assets |
Icons and imported files, served over plugin-asset:// |
dialog, fs, logger |
File pickers, scoped filesystem reads, logging |
Views
A plugin’s React tree is reconciled in main and streamed to the renderer as a
snapshot, which draws it with @vimputer/ui. The vocabulary is page, section,
list, list.item, text-field, button, audio, drop-zone, empty, and
status. Page views appear under Settings → Integrations; author views open inside
Add from plugin.
Loading
- First-party: modules under
apps/desktop/plugins/<id>/, imported directly inapps/desktop/src/main/index.ts. Noplugin.json. - Local: a folder in
~/Library/Application Support/Vimputer/plugins/<id>/with aplugin.jsongivingidandentry, the path to a built JS module. There is no install UI.
Services start when the app starts or the plugin is enabled, restart on reload, and are disposed when the plugin is disabled or the app quits.
Layers from plugins
Add from plugin writes a group’s items into the current layer as ordinary
plugin_action bindings and records the source in the layer’s pluginStamps.
Re-picking a stamped group adds only items whose action and params match no binding
in the layer. Nothing is ever removed or overwritten.
Reference implementations: Shottr is the smallest (URL scheme, no service). OBS and Paseo are live services. Soundboard builds its inventory from instances and views.