super-productivity/docs/plugin-development.md
Benjamin 07ec51b1a5
feat(plugins): add onReady() API with IPC ping + fix consent write delay (#7578)
* feat(plugins): add onReady() API with IPC ping + fix consent write delay #7326

- Remove setTimeout(5000) from _getNodeExecutionConsent; write consent immediately
- Add plugin.onReady(fn) to PluginAPI — fires after plugin.js evaluation and IPC bridge confirmation
- Add _pingNodeBridge() in plugin.service.ts with 3-attempt retry (1s, 2s delays)
- Add triggerReady() and pingNodeBridge() to PluginRunner
- Show snack + set error state if IPC bridge unavailable after retries
- Add NODE_EXECUTION_BRIDGE_UNAVAILABLE translation key
- Add focused tests for onReady, triggerReady, pingNodeBridge, consent persistence
- Update plugin-development.md with onReady usage and nodeExecution guidance

* test(plugins): fix unused variable lint errors in spec files

* fix(plugins): guard triggerReady on instance.loaded; fix doc numbering

* fix(plugins): remove _triggerReady from public API, route ping via bridge, add retry tests

* fix(electron): add paths for @sp/sync-providers subpath exports (node moduleResolution compat)

* fix(plugins): centralize onReady, tear down runtime on activation error, add iframe onReady

Address review on #7578:
- All plugin load paths (startup, upload, reload, lazy) now go through _fireOnReady,
  ensuring the IPC ping + onReady fire on every successful load — not just lazy.
- activatePlugin error path now unloads the plugin runtime (hooks, buttons, side
  effects) before setting status='error', preventing partially-running plugins.
- Iframe PluginAPI now exposes onReady (fires on next microtask after plugin.js
  evaluates), matching the host-side contract for typed iframe plugins.

* fix(plugins): clean up half-loaded plugins on onReady error, test real retry util

Self-review followups:
- _fireOnReadyWithCleanup wraps the 3 non-activatePlugin load paths and tears
  down the plugin (unloadPlugin + remove from list + status='error' + snack)
  if the IPC ping or onReady callback throws. Previously, those paths only
  logged and rethrew, leaving partially-running plugins.
- Extracted retry loop into pure pingWithRetry utility; spec now exercises
  production code instead of an inline-replicated stub. Removed the old
  plugin-ping-node-bridge.spec.ts which was just testing its own copy of
  the logic.
- Documented iframe onReady semantic (fires on microtask, no ping) in both
  the source comment and docs/plugin-development.md, since cold-boot is not
  a concern for iframe plugins (rendered on demand).

* ci(plugins): use npm i for root install to tolerate override drift

The root lockfile pins app-builder-lib's transitive minimatch via the
`overrides` field. npm 10.9.7 (bundled with Node 22 in setup-node@v6)
flags this as drift and fails `npm ci`, while npm 11 accepts it.
ci.yml's main test job uses `npm i`, which tolerates the drift without
mutating the lockfile on disk.

Plugin-Tests has been red on every PR since 2026-05-08 for this reason.
The inner `npm ci` for plugin-specific deps stays strict.

* fix(plugins): make onReady optional, assert callback isolation in spec

- packages/plugin-api/src/types.ts: mark onReady? optional on the public
  PluginAPI interface so existing plugin TypeScript typings (and any
  third-party PluginAPI implementations) remain assignable after upgrade.
  The host runtime already treats onReady as optional (no-op if no
  registration callback is provided), so this aligns the type with the
  actual contract.

- src/app/plugins/plugin-runner.spec.ts: the previous isolation test only
  asserted that triggerReady() resolved for both plugins; it would still
  pass if triggerReady fired every registered callback. The updated test
  wires per-plugin Jasmine spies through globalThis (the same context the
  plugin code's `new Function` runs in) and asserts call counts before
  and after each triggerReady, actually proving isolation.

* refactor(plugins): test real consent logic; scope startup snacks; tighten ping timeout

Address review feedback on PR #7578:

- Extract consent decision into pure `decideNodeExecutionConsent` util so the
  spec exercises real code instead of a reimplemented stub. Delete the
  stub-based plugin-consent.spec.ts and plugin-fire-on-ready.spec.ts (the
  latter was orchestration glue already covered by plugin-runner.spec.ts and
  ping-with-retry.util.spec.ts).
- Reduce per-ping timeout 5000ms -> 1500ms. Worst-case cold-boot bridge-down
  detection drops from ~17s to ~7.5s; in-process vm script returning true
  doesn't need 5s.
- Add PLUGIN_LOAD_FAILED translation wrapping plugin name + error. Strip the
  now-redundant pluginName from NODE_EXECUTION_BRIDGE_UNAVAILABLE.
- Scope activation-failure snack to manual activations only — startup
  auto-activation failures stay silent (plugin tile shows error state).
  _handleReadyFailure still snacks unconditionally since onReady failure
  leaves a partially-loaded runtime that the user needs to see.

---------

Co-authored-by: Benjamin <1159333+benjaminburzan@users.noreply.github.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-14 13:41:01 +02:00

24 KiB
Raw Blame History

Super Productivity Plugin Development Guide

This is a comprehensive documentation of the Super Productivity Plugin System. This guide covers everything you need to know about creating plugins for Super Productivity.

These docs might not always be perfectly up to date. You find the latest typescript interfaces here: types.ts

Personally I think the best way to figure out how to write a plugin is to check out the example plugins:

If you want to build a sophisticated UI there is a boilerplate available for solidjs: boilerplate-solid-js


Table of Contents

Quick Start

1. Basic Plugin Structure

my-plugin/
├── manifest.json      # Plugin metadata (required)
├── plugin.js          # Main plugin code that is launched when activated and when Super Productivity starts
├── index.html         # UI interface (optional) => requires iFrame:true in manifest
└── icon.svg           # Plugin icon (optional)

2. Minimal Example

manifest.json:

{
  "id": "hello-world",
  "name": "Hello World Plugin",
  "version": "1.0.0",
  "description": "My first Super Productivity plugin",
  "manifestVersion": 1,
  "minSupVersion": "14.0.0"
}

plugin.js:

console.log('Hello World plugin loaded!');

// Show a notification
PluginAPI.showSnack({
  msg: 'Hello from my plugin!',
  type: 'SUCCESS',
});

// Demo a simple counter
await PluginAPI.setCounter('hello-count', 0);
PluginAPI.registerHeaderButton({
  label: 'Hello (Count: 0)',
  icon: 'waving_hand',
  onClick: async () => {
    const newCount = await PluginAPI.incrementCounter('hello-count');
    PluginAPI.showSnack({
      msg: `Button clicked! Count: ${newCount}`,
      type: 'INFO',
    });
  },
});

Plugin Manifest

The manifest.json file is required for all plugins and defines the plugin's metadata and configuration.

Manifest Fields

Field Type Required Description
id string Unique identifier for your plugin (use kebab-case)
name string Display name shown to users
version string Semantic version (e.g., "1.0.0")
description string Brief description of what your plugin does
manifestVersion number Currently must be 1
minSupVersion string Minimum Super Productivity version required
author string Plugin author name
homepage string Plugin website or repository URL
icon string Path to icon file (SVG recommended)
iFrame boolean Whether plugin uses iframe UI (default: false)
sidePanel boolean Show plugin in side panel (default: false), requires iFrame:true
permissions string[] The permissions the plugin needs (e.g., ["nodeExecution"])
hooks string[] App events to listen to
uiKit boolean Enable UI Kit CSS reset for iframe plugins (default: true). Set to false to disable.

Complete Manifest Example

{
  "id": "my-advanced-plugin",
  "name": "My Advanced Plugin",
  "version": "2.1.0",
  "description": "An advanced plugin with UI and hooks",
  "manifestVersion": 1,
  "minSupVersion": "14.0.2",
  "author": "John Doe",
  "homepage": "https://github.com/johndoe/my-plugin",
  "icon": "icon.svg",
  "iFrame": true,
  "sidePanel": false,
  "permissions": ["nodeExecution"],
  "hooks": ["taskComplete", "taskUpdate", "currentTaskChange"]
}

Plugin Types

1. JavaScript Plugins (plugin.js)

Pure JavaScript plugins that run in a sandboxed environment with full API access.

Use when:

  • For setup background stuff that is to be executed even when the plugin ui (iFrame) is not shown
  • For registering and handling keyboard shortcuts
  • You want to listen to app hooks/events
  • You need programmatic interaction with tasks/projects

Example:

// Register multiple UI elements
PluginAPI.registerHeaderButton({
  label: 'My Button',
  icon: 'star',
  onClick: async () => {
    const tasks = await PluginAPI.getTasks();
    console.log(`You have ${tasks.length} tasks`);
  },
});

PluginAPI.registerHook(PluginAPI.Hooks.TASK_COMPLETE, (taskId) => {
  console.log(`Task ${taskId} completed!`);
});

2. HTML/Iframe Plugins (index.html)

Plugins that render custom UI in a sandboxed iframe.

Use when:

  • You need custom UI/visualizations
  • You want to display charts, forms, or complex interfaces

Important: When using iframes, you must inline all CSS and JavaScript directly in the HTML file. External stylesheets and scripts are blocked for security reasons.

Example index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>My Plugin UI</title>

    <!-- CSS must be inlined. Theme variables and UI Kit are injected automatically. -->
    <style>
      body {
        padding: var(--s3);
      }

      .task-list {
        background: var(--card-bg);
        border-radius: var(--card-border-radius);
        padding: var(--s2);
        box-shadow: var(--whiteframe-shadow-2dp);
      }

      .task-item {
        padding: var(--s);
        border-bottom: 1px solid var(--divider-color);
      }
    </style>
  </head>
  <body>
    <h1>My Plugin</h1>
    <div id="content">
      <button id="loadTasks">Load Tasks</button>
      <div
        id="taskList"
        class="task-list"
      ></div>
    </div>

    <!-- JavaScript must be inlined -->
    <script>
      document.getElementById('loadTasks').addEventListener('click', async () => {
        try {
          const tasks = await PluginAPI.getTasks();
          const taskList = document.getElementById('taskList');

          taskList.innerHTML = '<h3>Your Tasks:</h3>';

          tasks.forEach((task) => {
            const taskEl = document.createElement('div');
            taskEl.className = 'task-item';
            taskEl.textContent = task.title;
            taskList.appendChild(taskEl);
          });

          PluginAPI.showSnack({
            msg: `Loaded ${tasks.length} tasks`,
            type: 'SUCCESS',
          });
        } catch (error) {
          console.error('Error loading tasks:', error);
          PluginAPI.showSnack({
            msg: 'Failed to load tasks',
            type: 'ERROR',
          });
        }
      });
    </script>
  </body>
</html>

Theme Variables & UI Kit

Iframe plugins automatically receive:

  1. CSS variables — All theme variables (colors, spacing, shadows, transitions) are injected as CSS custom properties on :root. Use var(--c-primary), var(--bg), var(--text-color), etc.

  2. UI Kit CSS reset — By default, basic HTML elements (button, input, select, textarea, table, a, h1h6, p, code, pre, hr, etc.) are styled to match the app's look. This is injected before your plugin's own styles, so your CSS always wins.

    To disable the UI Kit, add "uiKit": false to your manifest.

Button variants:

  • Default <button> — Neutral card-background button with border
  • <button class="btn-primary"> — Filled primary-color button (white text)
  • <button class="btn-outline"> — Transparent button with primary-color border and text, fills on hover

Card component:

  • <div class="card"> — Card with background, shadow, rounded corners, and border
  • <div class="card card-clickable"> — Adds hover lift effect and primary border highlight

Utility classes:

  • .text-muted — Muted text color (var(--text-color-muted))
  • .text-primary — Primary theme color (var(--c-primary))
  • .page-fade — Fade-in animation (0.3s ease)

Key CSS variables:

  • --bg, --bg-darker — Background colors
  • --text-color, --text-color-muted — Text colors
  • --c-primary, --c-accent, --c-warn — Theme colors
  • --card-bg, --card-shadow, --card-border-radius — Card styling
  • --divider-color — Border/divider color
  • --s, --s2, --s3, --s4, --s-half, --s-quarter — Spacing scale
  • --transition-standard — Standard transition
  • --font-primary-stack — App font stack
  • --whiteframe-shadow-1dp through --whiteframe-shadow-24dp — Elevation shadows
  • --is-dark-theme1 if dark theme, 0 if light

Available API Methods

Data Operations

Tasks

  • getTasks() - Get all active tasks
  • getArchivedTasks() - Get archived tasks
  • getCurrentContextTasks() - Get tasks in current context
  • addTask(task) - Create a new task
  • updateTask(taskId, updates) - Update existing task

Projects

  • getAllProjects() - Get all projects
  • addProject(project) - Create new project
  • updateProject(projectId, updates) - Update project

Tags

  • getAllTags() - Get all tags
  • addTag(tag) - Create new tag
  • updateTag(tagId, updates) - Update tag

Simple Counters

Simple counters let you track lightweight metrics (e.g., daily clicks or habits) that persist and sync with your data. There are two levels: basic (key-value pairs for today's count) and full model (full CRUD on SimpleCounter entities with date-specific values).

Basic Counters

These treat counters as a simple { [id: string]: number } map for today's values (auto-upserts via NgRx).

Method Description Example
getAllCounters() Get all counters as { [id: string]: number } const counters = await PluginAPI.getAllCounters(); console.log(counters['my-key']);
getCounter(id) Get today's value for a counter (returns null if unset) const val = await PluginAPI.getCounter('daily-commits');
setCounter(id, value) Set today's value (non-negative number; validates id regex /^[A-Za-z0-9_-]+$/) await PluginAPI.setCounter('daily-commits', 5);
incrementCounter(id, incrementBy = 1) Increment and return new value (floors at 0) const newVal = await PluginAPI.incrementCounter('daily-commits', 2);
decrementCounter(id, decrementBy = 1) Decrement and return new value (floors at 0) const newVal = await PluginAPI.decrementCounter('daily-commits');
deleteCounter(id) Delete the counter await PluginAPI.deleteCounter('daily-commits');

Example:

// Track daily commits
let commits = (await PluginAPI.getCounter('daily-commits')) ?? 0;
await PluginAPI.incrementCounter('daily-commits');
PluginAPI.showSnack({
  msg: `Commits today: ${await PluginAPI.getCounter('daily-commits')}`,
  type: 'INFO',
});
Full SimpleCounter Model

For advanced use: Full CRUD on counters with metadata (title, enabled state, date-specific values via countOnDay: { [date: string]: number }).

Method Description Example
getAllSimpleCounters() Get all as SimpleCounter[] const all = await PluginAPI.getAllSimpleCounters();
getSimpleCounter(id) Get one by id (returns undefined if not found) const counter = await PluginAPI.getSimpleCounter('my-id');
updateSimpleCounter(id, updates) Partial update (e.g., { title: 'New Title', countOnDay: { '2025-11-17': 10 } }) await PluginAPI.updateSimpleCounter('my-id', { isEnabled: false });
toggleSimpleCounter(id) Toggle isOn state (throws if not found) await PluginAPI.toggleSimpleCounter('my-id');
setSimpleCounterEnabled(id, isEnabled) Set enabled state await PluginAPI.setSimpleCounterEnabled('my-id', true);
deleteSimpleCounter(id) Delete by id await PluginAPI.deleteSimpleCounter('my-id');
setSimpleCounterToday(id, value) Set today's value (YYYY-MM-DD) await PluginAPI.setSimpleCounterToday('my-id', 10);
setSimpleCounterDate(id, date, value) Set value for specific date (validates YYYY-MM-DD) await PluginAPI.setSimpleCounterDate('my-id', '2025-11-16', 5);

Example:

// Create/update a habit counter
await PluginAPI.updateSimpleCounter('habit-streak', {
  title: 'Daily Streak',
  type: 'ClickCounter',
  isEnabled: true,
  countOnDay: { '2025-11-17': 1 }, // Today's count
});
await PluginAPI.toggleSimpleCounter('habit-streak');
const counter = await PluginAPI.getSimpleCounter('habit-streak');
console.log(`Streak on: ${counter.isOn}`);

UI Operations

Notifications

// Show snackbar notification
PluginAPI.showSnack({
  msg: 'Operation completed!',
  type: 'SUCCESS', // SUCCESS, ERROR, INFO, WARNING
  ico: 'check', // Optional Material icon
  actionStr: 'Undo', // Optional action button
  actionFn: () => console.log('Undo clicked'),
});

// System notification
PluginAPI.notify({
  title: 'Task Complete',
  body: 'Great job!',
  ico: 'done',
});

Dialogs

// Open a dialog
const result = await PluginAPI.openDialog({
  title: 'Confirm Action',
  content: 'Are you sure?',
  okBtnLabel: 'Yes',
  cancelBtnLabel: 'No',
});

Registration Methods (plugin.js only)

Header Button

PluginAPI.registerHeaderButton({
  id: 'my-header-btn', // Optional unique ID
  label: 'Click Me',
  icon: 'star', // Material icon name
  onClick: () => {
    console.log('Header button clicked');
  },
});

Menu Entry

PluginAPI.registerMenuEntry({
  label: 'My Plugin Action',
  icon: 'extension',
  onClick: () => {
    console.log('Menu item clicked');
  },
});

Side Panel Button

PluginAPI.registerSidePanelButton({
  label: 'My Panel',
  icon: 'dashboard',
  onClick: () => {
    PluginAPI.showIndexHtmlAsView();
  },
});

Keyboard Shortcut

PluginAPI.registerShortcut({
  keys: 'ctrl+shift+p',
  label: 'My Plugin Shortcut',
  action: () => {
    console.log('Shortcut triggered');
  },
});

Hooks

// Available hooks
const hooks = {
  TASK_COMPLETE: 'taskComplete',
  TASK_UPDATE: 'taskUpdate',
  TASK_DELETE: 'taskDelete',
  CURRENT_TASK_CHANGE: 'currentTaskChange',
  FINISH_DAY: 'finishDay',
  LANGUAGE_CHANGE: 'languageChange',
  PERSISTED_DATA_UPDATE: 'persistedDataUpdate',
  ACTION: 'action',
};

// Register hook listener
PluginAPI.registerHook(PluginAPI.Hooks.TASK_COMPLETE, (taskId) => {
  console.log(`Task ${taskId} completed!`);
});

// Listen to Redux actions
PluginAPI.registerHook(PluginAPI.Hooks.ACTION, (action) => {
  if (action.type === 'ADD_TASK_SUCCESS') {
    console.log('New task added:', action.payload);
    // Bonus: Increment a counter on task add
    PluginAPI.incrementCounter('tasks-added-today');
  }
});

Data Persistence

You can persist data that will also be synced vai the persistDataSynced and loadSyncedData APIs. For local storage I recommend using localStorage.

// Save plugin data
await PluginAPI.persistDataSynced(JSON.stringify({ count: 42 }));

// Load saved data
const data = await PluginAPI.loadSyncedData();
console.log(data); // '{ count: 42 }'

Best Practices

1. Performance

  • Lazy load resources: Don't load everything on plugin initialization
  • Be responsive with using resources: Avoid heavy operations and don't save excessive amounts of data.
  • Keep it lightweight: Super Productivity is not the only app on the users system and your plugin is not the only plugin.

2. User Experience

  • Provide feedback: Show loading states and confirmations
  • Be non-intrusive: Don't spam notifications
  • Follow the app's design: Use the injected theme variables and try to keep styles minimal.
  • Respect user preferences: Check dark mode, and language settings (if possible or stick to english if not)

3. Security

  • Request minimal permissions: Only what you need

Node.js Script Execution

Plugins with "permissions": ["nodeExecution"] can run Node.js scripts in the Electron desktop app.

const result = await plugin.executeNodeScript({
  script: `
    const os = require('os');
    return os.hostname();
  `,
  timeout: 5000,
});

if (result.success) {
  console.log('Hostname:', result.result);
}

Important — use plugin.onReady() for startup calls:

executeNodeScript requires the Electron IPC bridge to be available. On cold boot this bridge may not be ready when plugin.js first runs. Always put executeNodeScript calls (and any other startup init code) inside plugin.onReady():

// ❌ May fail on cold boot
const result = await plugin.executeNodeScript({ script: 'return true' });

// ✅ Correct — fires after the bridge is confirmed available
plugin.onReady(async () => {
  const result = await plugin.executeNodeScript({ script: 'return true' });
});

plugin.onReady(fn) fires after plugin.js has fully evaluated and the app has confirmed the Node.js IPC bridge is responding (with automatic retry). If the bridge is unavailable after retries, an error is shown in the plugin management UI and onReady does not fire.

You can also use onReady for any other startup work that should run after the plugin script has finished setting up its hooks and registrations — not just for nodeExecution.

Iframe plugins: plugin.onReady() is also available inside iframe plugins, but it fires on the next microtask after plugin.js finishes evaluating — without an IPC bridge ping. This is fine in practice because iframe plugins are rendered on user navigation (well after host startup, when the bridge is already up). If your iframe plugin needs the bridge from onReady, it will be available; cold-boot races affect host-side plugin code only.

4. Don't spam the logs

console.logs should be kept to a minimum.

5. Iframe plugins: inline everything

  1. Inline everything: CSS and JavaScript must be in the HTML file
<!-- Good: Everything inlined -->
<!DOCTYPE html>
<html>
  <head>
    <style>
      /* All styles here */
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script>
      // All JavaScript here
    </script>
  </body>
</html>

Security Considerations

Sandboxing

  • JavaScript plugins run in isolated VM contexts
  • Iframe plugins run in sandboxed iframes with restricted permissions
  • No access to file system unless through API

API Restrictions

In iframe context, these methods are NOT available:

  • registerHeaderButton()
  • registerMenuEntry()
  • registerSidePanelButton()
  • registerShortcut()
  • registerHook()
  • execNodeScript()

Content Security Policy

  • External scripts/styles are blocked in iframes
  • Only same-origin resources are allowed
  • Inline scripts must be within the HTML file

Testing Your Plugin

1. Local Development

  1. Use "Load Plugin from Folder" to test your plugin
  2. Open DevTools (F12 or Ctrl+Shift+i) to see console logs
  3. Use the API Test Plugin as reference

2. Debugging Tips

// Add debug logging
const DEBUG = true;

function log(...args) {
  if (DEBUG) {
    console.log('[MyPlugin]', ...args);
  }
}

// Test API methods
async function testAPI() {
  log('Testing getTasks...');
  const tasks = await PluginAPI.getTasks();
  log('Tasks:', tasks);

  log('Testing showSnack...');
  PluginAPI.showSnack({
    msg: 'API test successful!',
    type: 'SUCCESS',
  });
}

3. Common Issues

Plugin not loading:

  • Check manifest.json syntax
  • Verify minSupVersion compatibility
  • Look for errors in console

API methods failing:

  • Check if method is available in current context
  • Verify permissions in manifest
  • If executeNodeScript fails on startup or cold boot, wrap your init code in plugin.onReady(async () => { ... }) — this ensures the Node.js bridge is ready before your code runs

Iframe not displaying:

  • Check that all resources are inlined
  • Verify no external dependencies
  • Look for CSP violations in console

Resources

Contributing

If you create a useful plugin, consider:

  1. Posting on reddit or GitHub discussions about it
  2. Submitting a PR to add it to the community plugins list (coming soon)

Happy plugin development! 🚀

Bonus: Vibe Coding your Plugins

Tips

  • Don't test on your real world data! Use a test instance! (you can use https://test-app.super-productivity.com/ if you don't know how get one)
  • Be as specific as possible
  • Outline what APIs your plugin should use
  • Test for errors (Ctrl+Shift+i opens the console) and iterate until it works. Don't expect that everything works on your first try.
  • Read the code! Don't trust it blindly.

Example

Can you you write me a plugin for Super Productivity that plays a beep sound every time i click on a header button (You need to add a header button via PluginAPI.registerHeaderButton).

Here are the docs: https://github.com/super-productivity/super-productivity/blob/master/docs/plugin-development.md

Don't use any PluginAPI methods that are not listed in the guide.

Please give me the output as flat zip file to download.