mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* feat(plugins): persist nodeExecution consent per plugin (#8512) Phase 2 of #8512: remember an uploaded plugin's nodeExecution consent so it is asked once, not every app session, while keeping the trust decision local to the device. - Main-owned, local-only store (electron/plugin-node-consent-store.ts, wrapping simple-store under key 'pluginNodeExecutionConsent'); never pfapi-synced, so a grant on one device never auto-grants on another. - Ask-once is scoped to UPLOADED plugins; built-in plugins (sync-md) keep the per-session verified prompt unchanged (regression-safe). - Consent is written only after a native Allow in main; the renderer has a delete-only clearConsent IPC (fail-safe) and no way to self-grant. - Cleared on disable / uninstall / re-upload (never in generic teardown), so revoke = the existing disable toggle and changed code re-consents. - No code hash: re-ask-on-change is structural (re-upload clears consent); a renderer hash would be forgeable and security-worthless. version:1 is the migration anchor if main-owned hashing is ever added. Tests: electron 154 pass (consent-store + executor ask-once/deny/clear/ built-in-never-persisted); 474 plugin specs pass incl. the sync-exclusion guard. Docs updated. * fix(plugins): harden persisted consent against prototype-pollution ids Multi-review (security) found a CRITICAL in the Phase 2 consent store: the consents map was a plain object keyed on an attacker-controlled pluginId, so an uploaded plugin with id 'constructor' / 'toString' / 'valueOf' / 'hasOwnProperty' resolved consents[id] to the inherited Object.prototype member (a truthy function). The executor's ask-once check treated that as a prior grant and minted a nodeExecution token with NO consent dialog on a fresh install — full code execution with zero user approval. ('__proto__' was already blocked by the id allowlist; these names pass it.) Unit tests missed it because the executor test stub used a Map, which is immune to the footgun. Fix: - Store: null-prototype consents map (Object.create(null)) + own-property (hasOwnProperty) guarded reads + reject non-object entries. Closes the class for any prototype-member id, including across a disk round-trip. - Executor: reject __proto__/prototype/constructor in assertSafePluginId as defense-in-depth at the boundary. - Regression tests: consent store returns null for prototype-member ids (fresh, after a real set, after clear); grant request for these ids is rejected with no dialog and no mint. Also from review: ask-once path now re-checks the sender URL after the consent read (parity with the dialog path); clarified why the consent mutation queue is not redundant with simple-store's save queue. Electron suite 156/156 pass. * refactor(plugins): log consent persist-failure via electron-log Multi-review follow-ups (non-blocking): - Route the best-effort consent persist-failure to electron-log/main (the user-exportable host log) instead of console; console in the executor is otherwise the sandboxed plugin's own output. Only the validated id is logged. - Clarify the disable-path comment: clearing revokes the live session grant always, and the persisted consent only for uploaded plugins (built-ins have none). * fix(plugins): clear persisted consent on cache-clear and disclose persistence in dialog Two gaps found in multi-agent review of the Phase 2 persisted-consent feature: - clearUploadedPluginsFromMemory() (the 'Clear plugin cache' button) wiped the plugin code from IndexedDB but left the main-owned persisted nodeExecution consent behind. A later re-upload of the same id has no existingState, so the re-upload consent-clear in loadPluginFromZip never fired and the (possibly different) code was silently granted node execution with no prompt — defeating the 'replacing code under an id always re-asks' invariant. Now clears consent for every evicted uploaded id, mirroring removeUploadedPlugin. - The uploaded-plugin native consent dialog still said access was valid 'for this app session', but Allow is now persisted across sessions. The prompt now discloses that the choice is remembered on the device until disable / remove / re-upload, so the user consents to the actual scope. Regression tests added on both sides. * refactor(plugins): key persisted consent on a Map, not a null-prototype object Multi-review simplification. The consent store keyed an attacker-controlled pluginId into a plain object, defended against `Object.prototype` member names (constructor/toString/…) with a null-prototype object + hasOwn guards + a typeof-object read check. A `Map` makes that safety structural and self-evident — an unstored key is simply `undefined` — and matches the sibling `grants` Map in the executor. The on-disk format is unchanged (a plain {version, consents} object); the Map is serialized via Object.fromEntries (define-semantics, no prototype write) and rebuilt via Object.entries with the well-formedness guard moved to load time. Also corrects the stale 'never downgrade-corrupt it' comment with the accurate downgrade behavior, and adds a round-trip test proving a hand-edited on-disk __proto__ data key loads inertly without polluting Object.prototype. * refactor(plugins): funnel disable through PluginService.disablePlugin and de-dup dialog display Two more multi-review items, now that we own the PR: - 'Disabling a node plugin revokes its consent' previously lived only in the plugin-management UI handler, so a future programmatic disable path could unload the plugin yet leave persisted consent behind — re-enabling would then silently re-grant node execution. Added PluginService.disablePlugin(setEnabled=false + unload + clearNodeExecutionConsent) and routed the UI through it, making the revoke a structural invariant. The consent clear is a safe no-op for non-node plugins, so the previous requiresNodeExecution gate is dropped. - The uploaded-plugin name/version were sanitized once for the dialog and again for persistence (same lengths/fallbacks, duplicated). Extracted sanitizedUploadedDisplay as the single source of truth so the persisted record always matches what the user saw in the prompt. Tests added for the disablePlugin invariant. * fix(plugins): harden persisted nodeExecution consent (multi-review) Follow-ups from a multi-agent review of #8600: - Re-ask structurally on every upload: clear consent unconditionally in loadPluginFromZip (outside the `existingState` branch) so a same-id re-upload always re-prompts even if consent was orphaned (crash mid-uninstall, IndexedDB eviction, external/partial wipe). - Fail closed on upload: clearNodeExecutionConsent reports a persist failure via its return value; loadPluginFromZip aborts the upload if the prior consent could not be revoked, so replacement code can't inherit a stale grant. Lifecycle edges (disable/uninstall/cache-clear) ignore the result so a rare disk failure can't abort their bookkeeping. - Mint the grant before the best-effort consent persist in the executor so a navigation/destroy during the write drops it via cleanup and a persist failure can't lose an approved grant. - Validate the full consent record shape on load so a corrupt {}/array entry can't read as a grant. - Log only the validated id + error code on persist failure (no userData path). - Fix a stale comment (the store keys consent in a Map, not null-proto objects). Adds regression tests: mint-before-persist ordering, best-effort persist, malformed-entry rejection, and the consent-clear fail-closed return contract. * test(plugins): add clearNodeExecutionConsent to PluginBridgeService spy loadPluginFromZip now clears prior persisted nodeExecution consent before loading replacement code (#8512 Phase 2). The spy in this spec lacked the method, so the call threw, was caught, returned false, and aborted the upload, failing both load-from-zip tests.
807 lines
31 KiB
Markdown
807 lines
31 KiB
Markdown
# 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](../packages/plugin-api/src/types.ts)
|
||
|
||
Personally I think the best way to figure out how to write a plugin is to check out the example plugins:
|
||
|
||
- [yesterday-tasks-plugin](../packages/plugin-dev/yesterday-tasks-plugin)
|
||
- [procrastination-buster](../packages/plugin-dev/procrastination-buster)
|
||
- [api-test-plugin](../packages/plugin-dev/api-test-plugin)
|
||
|
||
If you want to build a sophisticated UI there is a boilerplate available for solidjs:
|
||
[boilerplate-solid-js](../packages/plugin-dev/boilerplate-solid-js)
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
- [Quick Start](#quick-start)
|
||
- [Plugin Manifest](#plugin-manifest)
|
||
- [Plugin Types](#plugin-types)
|
||
- [Available API Methods](#available-api-methods)
|
||
- [Best Practices](#best-practices)
|
||
- [Security Considerations](#security-considerations)
|
||
- [Testing Your Plugin](#testing-your-plugin)
|
||
|
||
## Quick Start
|
||
|
||
### 1. Basic Plugin Structure
|
||
|
||
```
|
||
my-plugin/
|
||
├── manifest.json # Plugin metadata (required)
|
||
├── plugin.js # Host-side plugin code (optional for iframe-only plugins)
|
||
├── index.html # UI interface (required when omitting plugin.js; requires iFrame:true in manifest)
|
||
└── icon.svg # Plugin icon (optional)
|
||
```
|
||
|
||
`plugin.js` is required for plugins that need host-side setup at plugin load time,
|
||
shortcuts, header buttons, background behavior, or host-side API handlers. A UI-only
|
||
iframe plugin can ship only `manifest.json` and `index.html` when the manifest sets
|
||
`iFrame: true`.
|
||
|
||
### 2. Minimal Example
|
||
|
||
**manifest.json:**
|
||
|
||
```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:**
|
||
|
||
```javascript
|
||
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 |
|
||
| `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
|
||
|
||
```json
|
||
{
|
||
"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": ["getTasks", "updateTask"],
|
||
"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:**
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
Iframe-only plugins do not need a `plugin.js` file if all plugin behavior lives inside
|
||
`index.html`. Super Productivity automatically adds the default menu or side-panel entry
|
||
from the manifest when the plugin is loaded.
|
||
|
||
**Important:** Iframe plugins are served from a sandboxed blob document and talk to
|
||
the host only through the filtered Plugin API message bridge. Inline CSS, JavaScript,
|
||
and small assets directly in `index.html`; arbitrary extra files from the ZIP are not
|
||
served to the iframe. External URLs can work when the app/runtime CSP allows them, but
|
||
they are not part of the portable plugin contract.
|
||
|
||
**Example index.html:**
|
||
|
||
```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`, `h1`–`h6`, `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-theme` — `1` 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
|
||
- `getSelectedTask()` - Get the task selected in the task detail panel, or `null`
|
||
- `getFocusedTask()` - Get the currently focused task row, or `null`. Task-row focus is cleared when focus moves elsewhere, including into iframe side panels; use `getSelectedTask()` for persistent side-panel task context.
|
||
- `addTask(task)` - Create a new task
|
||
- `updateTask(taskId, updates)` - Update existing task
|
||
|
||
#### Application State
|
||
|
||
- `getAppState()` - Get the current application state (read-only; returns `PluginAppState`). An overview of the data returned is the JSON file exported via `Settings > Sync & Backup > Import/Export > Export data`. Example: `const state = await PluginAPI.getAppState();`
|
||
|
||
#### 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:**
|
||
|
||
```javascript
|
||
// 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:**
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
```javascript
|
||
// Open a dialog
|
||
const result = await PluginAPI.openDialog({
|
||
title: 'Confirm Action',
|
||
htmlContent: '<p>Are you sure?</p>',
|
||
buttons: [{ label: 'No' }, { label: 'Yes', color: 'primary', raised: true }],
|
||
});
|
||
|
||
if (result === 'Yes') {
|
||
// Continue with the confirmed action
|
||
}
|
||
```
|
||
|
||
`openDialog()` resolves with the clicked button label. If the user dismisses
|
||
the dialog without clicking a button, it resolves with `undefined`. The legacy
|
||
`content`, `okBtnLabel`, and `cancelBtnLabel` fields are still accepted, but new
|
||
plugins should use `htmlContent` and `buttons`.
|
||
|
||
### Registration Methods (plugin.js only)
|
||
|
||
#### Header Button
|
||
|
||
```javascript
|
||
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
|
||
|
||
```javascript
|
||
PluginAPI.registerMenuEntry({
|
||
label: 'My Plugin Action',
|
||
icon: 'extension',
|
||
onClick: () => {
|
||
console.log('Menu item clicked');
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Side Panel Button
|
||
|
||
```javascript
|
||
PluginAPI.registerSidePanelButton({
|
||
label: 'My Panel',
|
||
icon: 'dashboard',
|
||
onClick: () => {
|
||
PluginAPI.showIndexHtmlAsView();
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Keyboard Shortcut
|
||
|
||
```javascript
|
||
PluginAPI.registerShortcut({
|
||
keys: 'ctrl+shift+p',
|
||
label: 'My Plugin Shortcut',
|
||
action: () => {
|
||
console.log('Shortcut triggered');
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Hooks
|
||
|
||
```javascript
|
||
// 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_CHANGED: 'persistedDataChanged',
|
||
ACTION: 'action',
|
||
};
|
||
```
|
||
|
||
`PERSISTED_DATA_CHANGED` fires whenever this plugin's persisted data
|
||
changes — local writes, remote sync deliveries, and bulk imports —
|
||
_after_ the host has finished its initial boot load. The handler
|
||
receives no payload; re-call `loadSyncedData(key?)` for any key your
|
||
plugin tracks to get fresh data. There is no replay-on-register and no
|
||
guaranteed ordering across rapid changes, so handlers must be
|
||
idempotent. The typical pattern is: call `loadSyncedData()` once on
|
||
plugin init, then subscribe to this hook for subsequent updates.
|
||
|
||
```javascript
|
||
// 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 via the `persistDataSynced` and
|
||
`loadSyncedData` APIs. Host-side `plugin.js` code can use `localStorage` for
|
||
data that should stay local. Iframe plugins should prefer the synced
|
||
persistence APIs because sandboxed iframe origins may not have reliable access
|
||
to browser storage.
|
||
|
||
```javascript
|
||
// 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 after the user allows the desktop permission prompt.
|
||
|
||
Both built-in and uploaded (community) plugins may request `nodeExecution`. The grant is
|
||
issued by the Electron **main** process after a native consent dialog and is bound to the
|
||
plugin id. For uploaded plugins the app cannot verify the manifest, so the dialog flags
|
||
the plugin as unverified third-party code with full machine access that Super Productivity
|
||
cannot sandbox, and defaults to **Deny** — only allow plugins whose source you trust. If
|
||
the user denies, the plugin stays enabled but its node calls fail until it is re-enabled.
|
||
|
||
Consent handling differs by plugin type:
|
||
|
||
- **Uploaded (community) plugins:** consent is remembered **once per plugin** in a
|
||
main-owned, local-only store (`Allow` is not asked again on the next launch). The
|
||
consent is **never synced** — granting on one device does not auto-grant on another;
|
||
the other device prompts afresh on first node use. Consent is automatically cleared
|
||
(forcing a fresh prompt) when you **disable**, **uninstall**, or **re-upload** the
|
||
plugin, so replacing a plugin's code under the same id always re-asks. To revoke access
|
||
without removing the plugin, simply disable it.
|
||
- **Built-in plugins** (e.g. `sync-md`) keep the per-session prompt and are not persisted.
|
||
|
||
> **Plugin id constraints (for `nodeExecution`):** the consent grant keys on your
|
||
> manifest `id`, so it must be a single safe token — no whitespace, control/bidi
|
||
> characters, `:`, path separators (`/`, `\`), and at most 100 characters. Lowercase
|
||
> kebab-case is recommended; dots and uppercase are accepted.
|
||
|
||
> **Security note:** a granted `nodeExecution` plugin can run any program with full
|
||
> access to your files and system. The file/IPC channel a plugin uses to talk to a
|
||
> companion process is an open local channel — treat any data it reads as untrusted
|
||
> input (never `eval`/`require` its contents).
|
||
|
||
```javascript
|
||
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()`:
|
||
|
||
```javascript
|
||
// ❌ 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:** `PluginAPI.onReady()` is available inside `index.html`. It fires on
|
||
the next microtask after the callback is registered — without an IPC bridge ping. This is
|
||
fine in practice because iframe plugins are rendered on user navigation (well after host
|
||
startup). Iframe API calls still go through the host bridge when they are made;
|
||
cold-boot bridge pings are only performed for host-side plugin code.
|
||
|
||
**Clean up with `plugin.onUnload()`:**
|
||
|
||
Code-based plugins (`plugin.js`) run directly in the app's renderer, so timers and
|
||
listeners they create are **not** cleaned up automatically when the plugin is disabled,
|
||
reloaded, or uninstalled — a `setInterval` started by your plugin keeps firing until the
|
||
app is fully reloaded. Register a teardown callback to clear them yourself:
|
||
|
||
```javascript
|
||
const intervalId = setInterval(doWork, 60000);
|
||
|
||
plugin.onUnload(() => {
|
||
clearInterval(intervalId);
|
||
// also: removeEventListener, speechSynthesis.cancel(), close connections, …
|
||
});
|
||
```
|
||
|
||
The host invokes the callback at the start of plugin teardown, while the Plugin API is
|
||
still usable for calls like persisting data — but don't register new hooks or listeners
|
||
from inside it (the plugin is going away; re-registering `onUnload` there is ignored).
|
||
The returned promise is **not awaited** — do synchronous cleanup (`clearInterval` etc.)
|
||
before any `await`, since teardown continues immediately. Registering again replaces the
|
||
previous callback, so register once and do all cleanup there. Errors thrown by the
|
||
callback are logged and do not block teardown.
|
||
|
||
Plugins distributed independently of the app should feature-detect it
|
||
(`if (plugin.onUnload) { ... }`) — hosts predating the hook don't provide it.
|
||
|
||
**Iframe plugins:** `onUnload` exists but is a no-op — the host unmounts the iframe on
|
||
unload, which takes its timers and listeners with it. Don't rely on it for unload-time
|
||
persistence in iframes; persist when the data changes instead.
|
||
|
||
### 4. Don't spam the logs
|
||
|
||
`console.logs` should be kept to a minimum.
|
||
|
||
### 5. Iframe plugins: keep assets self-contained
|
||
|
||
1. **Prefer self-contained HTML**: inline CSS, JavaScript, and small assets are the
|
||
most portable option for iframe plugins
|
||
|
||
```html
|
||
<!-- Portable: Everything needed by the iframe is in index.html -->
|
||
<!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
|
||
|
||
### Iframe API Surface
|
||
|
||
Iframe plugins receive a filtered `window.PluginAPI` object injected into `index.html`.
|
||
The iframe can use the injected task/project/tag APIs, dialog and notification APIs,
|
||
navigation helpers, persistence helpers, counters, action dispatch, `registerHook()`,
|
||
and `registerWorkContextHeaderButton()`. Callback-heavy registration methods such as
|
||
`registerHeaderButton()`, `registerMenuEntry()`, `registerSidePanelButton()`,
|
||
`registerShortcut()`, and `registerConfigHandler()` must be registered from
|
||
host-side `plugin.js` code. APIs not injected into the iframe are unavailable, even if
|
||
they exist on the host-side plugin bridge.
|
||
|
||
`executeNodeScript()` is proxied through the host bridge for iframe plugins when
|
||
the desktop app grants the plugin `nodeExecution` permission.
|
||
|
||
### Iframe Boundary
|
||
|
||
- Iframe plugins run without `allow-same-origin`, so they have an opaque origin
|
||
- Host access is limited to the filtered Plugin API `postMessage` bridge
|
||
- Remote assets depend on the app/runtime CSP and should not be relied on
|
||
|
||
## 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
|
||
|
||
```javascript
|
||
// 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
|
||
|
||
- **Plugin API Types**: [@super-productivity/plugin-api](https://www.npmjs.com/package/@super-productivity/plugin-api)
|
||
- **Plugin Boilerplate**: [boilerplate-solid-js](../packages/plugin-dev/boilerplate-solid-js)
|
||
- **Example Plugins**: [plugin-dev](../packages/plugin-dev)
|
||
- **Community Plugins**:
|
||
- [counter-tester-plugin](https://github.com/Mustache-Games/counter-tester-plugin) by [Mustache Dev](https://github.com/Mustache-Games)
|
||
- [sp-reporter](https://github.com/dougcooper/sp-reporter) by [dougcooper](https://github.com/dougcooper)
|
||
|
||
## 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
|
||
|
||
```md
|
||
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.
|
||
```
|