-
Notifications
You must be signed in to change notification settings - Fork 174
Plugin Development EN
For authors of local plugins. The current API is apiVersion: "1" (V1 / V1.1 / V1.2 / V1.3 share this major). User guide: Plugin usage. 中文:插件开发.
Implementation references: PR #357, docs/plugins/v1-development.md, examples/plugins/.
Plugins should request capabilities, not inherit Electron privileges.
- Plugins ask for Host-defined, Host-checked capabilities. They do not inherit Electron or Node privileges.
- The Host never passes GitHub tokens, AI keys, Electron APIs, raw IPC, or the Zustand store to a plugin.
- Worker code is trusted local Node.js.
worker_threads.Workerisolates lifecycle, crashes, and timeouts — it is not a security sandbox. Plugin code can still touch local files, environment variables, and the network. - Page content is untrusted: sandboxed iframe, custom protocol, CSP, validated
postMessage. - There is no plugin store, remote code, auto-update, or silent install.
Worker plugin:
my-plugin/
├─ manifest.json
└─ worker.js
A page plugin may omit main:
my-plugin/
├─ manifest.json
└─ ui/
├─ index.html
├─ index.js
└─ style.css
Install from Settings → Plugins → Install local plugin. The plugin stays disabled until the user confirms every permission listed in the Manifest.
Package limits: at most 2000 files, 50 MiB total, no symbolic links. Entry paths must stay inside the plugin directory; absolute paths, .. traversal, and symlink escapes are rejected.
Unknown top-level fields and unknown contribution fields are rejected. manifestVersion must be 1 and apiVersion must be "1".
{
"manifestVersion": 1,
"id": "com.example.markdown-exporter",
"name": "Markdown Exporter",
"version": "0.1.0",
"description": "Export selected repositories as Markdown",
"author": "Example",
"apiVersion": "1",
"main": "worker.js",
"permissions": ["repositories:read", "storage", "clipboard:write"],
"contributes": {
"repositoryActions": [
{
"id": "copy-repository",
"title": "Copy repository",
"placement": "repository-card"
}
],
"repositoryProcessors": [
{ "id": "health", "title": "Repository health" }
],
"releaseProcessors": [
{ "id": "recommend-asset", "title": "Recommend asset" }
],
"exporters": [
{
"id": "markdown",
"title": "Markdown",
"fileExtension": ".md",
"mimeType": "text/markdown"
}
],
"pages": [
{ "id": "dashboard", "title": "Repository Health", "entry": "ui/index.html" }
]
}
}| Field | Required | Rules |
|---|---|---|
manifestVersion |
yes | number 1
|
id |
yes |
/^[a-z0-9]+(?:[.-][a-z0-9]+)+$/, e.g. com.example.foo. Immutable after install |
name |
yes | non-empty string |
version |
yes | SemVer, e.g. 0.1.0
|
apiVersion |
yes | string "1"
|
description |
no | non-empty string |
author |
no | non-empty string |
main |
conditional | relative path to the Worker entry inside the plugin directory. Required when you declare repositoryActions, repositoryProcessors, releaseProcessors, or exporters. Page-only plugins may omit it |
permissions |
yes | array of unique strings |
contributes |
yes | object. You must have either main or at least one pages entry |
Contribution id: /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/, unique within that array.
| Contribution | Fields | Notes |
|---|---|---|
repositoryActions |
id, title, placement, optional icon
|
placement is repository-card or bulk-toolbar
|
repositoryProcessors |
id, title
|
Host API exists; no dedicated UI yet |
releaseProcessors |
id, title
|
Shown under “Plugin asset recommendations” |
exporters |
id, title, fileExtension, mimeType
|
fileExtension like .md (1–10 alphanumeric); mimeType like text/markdown
|
pages |
id, title, entry
|
entry must be an .html file |
Repository contributions (actions / processors / exporters) require repositories:read or privateRepositories:read. Release processors require releases:read.
Reserved permissions may appear in the Manifest; they do not unlock a Host API. The user must confirm the full set on enable. A later change to that set requires confirmation again.
| Permission | Behaviour |
|---|---|
repositories:read |
Read the Host’s loaded repository snapshot, including private starred metadata. Settings shows an extra notice |
privateRepositories:read |
Reserved as a separate filter. Today it covers the same private metadata as repositories:read, and also satisfies the repository-contribution permission check on its own |
releases:read |
Read the Host’s loaded Release / asset snapshot (public fields omit download URLs) |
storage |
Isolated JSON storage per plugin |
clipboard:write |
Lets an action suggest copying text |
external:open |
Lets an action open a credential-free HTTPS URL |
downloads:create |
Shows Host download on a recommendation; the user still picks the save location. The plugin never sees the URL or local path |
ai:invoke |
Page bridge only: after per-request confirmation, call the active AI provider. Not on Worker context
|
web:search |
Page bridge only: after per-request confirmation, query the user-configured SearXNG instance. Not on Worker context
|
repositories:write |
Reserved; V1 has no write API |
gists:read |
Reserved; V1 has no Gist query |
network:<domain> |
Reserved; V1 has no generic HTTP API. Domain must be a public hostname, not localhost
|
The entry is CommonJS:
module.exports = {
async activate(context) {},
async deactivate() {},
async runAction({ actionId, repositories }) {},
async runProcessor({ processorId, repositories }) {},
async runReleaseProcessor({ processorId, repository, release, hostEnvironment }) {},
async runExporter({ exporterId, repositories }) {},
};Lifecycle methods may be omitted. Declaring a contribution without the matching export fails (e.g. PLUGIN_ACTION_HANDLER_MISSING).
Each call times out after 5 seconds by default. A timeout or protocol error terminates that plugin runtime and moves it to error.
context is frozen and contains only:
{
pluginId: 'com.example.foo',
permissions: Object.freeze([...]),
log: { debug, info, warning, error },
storage?, // only if storage was granted
github?, // only if repositories:read / privateRepositories:read / releases:read
}Logging:
await context.log.info('Activated', { enabled: true });Common tokens, Authorization headers, and keys matching authorization|api-key|token|secret|password|credential are redacted. Messages are capped at 4000 characters; the log file at 1 MiB. Do not log private data. Plugin AI request bodies are not written to debug logs.
Storage (storage required):
await context.storage.set('settings', { enabled: true });
const settings = await context.storage.get('settings'); // null if missing
await context.storage.delete('settings');- key: 1–128 characters, not
__proto__/prototype/constructor - 64 KiB per value, 1 MiB per plugin
- values must be JSON-serializable;
getreturns a deep clone
GitHub queries hit the in-memory sanitized snapshot only — no live GitHub API:
const matches = await context.github.searchRepositories('electron', { limit: 20 });
const repository = await context.github.getRepository(repositoryId);
const release = await context.github.getRelease(releaseId);-
searchRepositories(query, { limit }):query1–200 chars;limit1–100, default 20. Matchesfull_name,description,topics -
getRepository/getRelease:nullif missing - repository methods need
repositories:readorprivateRepositories:read;getReleaseneedsreleases:read
The Worker has no ai.generate, web.search, generic fetch, filesystem, or download API.
The Host strips fields. Repository summary:
{
id, name, full_name, description, html_url,
stargazers_count, forks_count, language,
created_at, updated_at, pushed_at,
owner: { login },
topics, // at most 100 strings
license
}No README, local notes, AI chats, visit history, user identity, or credentials. repositories:read can still include that metadata for private starred repos.
Release summary:
{
id, tag_name, name, body, // body capped at 256 KiB
published_at, html_url, prerelease,
repository: { id, full_name, name },
assets: [{
id, name, size, download_count, content_type, created_at, updated_at
}] // at most 500; no browser_download_url
}runReleaseProcessor also receives:
hostEnvironment: { os: process.platform, arch: process.arch }For example { os: 'darwin', arch: 'arm64' } or { os: 'win32', arch: 'x64' }.
A single action / processor / exporter call receives at most 1000 repositories.
Unknown fields are rejected. Results must be JSON-serializable.
return { type: 'text', content: '...', suggestedAction: 'copy' }; // or 'save'
return { type: 'notice', level: 'info', message: 'Done' }; // info | warning | error
return { type: 'open-external', url: 'https://example.com' };-
copyrequiresclipboard:write -
open-externalrequiresexternal:openand a credential-free HTTPS URL - no HTML, no arbitrary Host commands
- 1 MiB max
Only repository IDs from the current input:
return {
repositories: [
{ id: 1, summary: 'Active', tags: ['healthy'], category: 'tools' }
]
};summary / tags / category are optional. tags is at most 100 strings. 1 MiB max. Nothing in the UI consumes these results yet.
return { content: '# Repositories', fileName: 'stars.md' };-
contentis plain text, 5 MiB max -
fileNameis optional and must not contain path separators. The ManifestfileExtensionwins; the Host appends it if the returned name lacks that suffix - MIME type comes from the Manifest, not the return value
return {
recommendedAssetId: 123,
confidence: 0.92,
reason: 'Windows x64 installer'
};-
recommendedAssetIdmust belong to the current Release -
confidencein[0, 1] -
reasonnon-empty, ≤ 2000 characters -
Host download appears only with
downloads:create. The Host fetches a GitHub HTTPS URL (8 GiB max); the plugin never sees the URL
A page Manifest may omit main. After enable, open it from Settings → Plugins.
The Host serves HTML / JS / CSS / images from the page directory over the plugin-page: protocol. Path traversal and symlinks that escape the page directory are rejected. manifest.json and the main entry cannot be read. 20 MiB per resource. Allowed extensions: .html .js .mjs .css .json .svg .png .jpg .jpeg .webp .gif .ico .woff .woff2.
The iframe uses sandbox="allow-scripts" and referrerPolicy="no-referrer". CSP denies network, nested frames, workers, forms, and inline scripts. Scripts and styles may load only from that plugin origin; images and fonts may also use data:.
Pre-build React / Vue / similar to static files with relative URLs. Do not load CDN runtimes.
On load the Host sends:
{ type: 'plugin-page:init', pluginId, pageId, token }Page request:
window.parent.postMessage({
type: 'plugin-page:request',
pluginId: 'com.example.repo-health-page',
pageId: 'dashboard',
requestId: 'request_1', // /^[a-zA-Z0-9_-]{1,64}$/
token,
method: 'repositories.search',
args: { query: 'react', limit: 20 },
}, '*');Response:
{ type: 'plugin-page:response', pluginId, pageId, requestId, token, success: true, value }
{ type: 'plugin-page:response', pluginId, pageId, requestId, token, success: false, error: { code, message } }The Host checks event.source, origin === 'null', pluginId, pageId, the one-shot token, and a field allowlist. Closing the page, disabling, or uninstalling drops resources and capability calls immediately. Reloading the iframe issues a new token.
Limits: 8 in-flight requests; 120 per minute; args JSON ≤ 1 MiB.
Every request is re-checked in the main process against plugin state, the page declaration, the argument schema, and Manifest permissions. Results are the sanitized in-memory snapshot, not live GitHub.
| method | Permission | args | Returns |
|---|---|---|---|
repositories.search |
repositories:read or privateRepositories:read
|
{ query, limit? } query ≤ 200, limit 1–100 |
repository array |
repositories.get |
same |
{ repositoryId } positive integer |
repository or failure |
releases.get |
releases:read |
{ releaseId } positive integer |
release (no download URL) |
storage.get / set / delete
|
storage |
{ key, value? } |
same as Worker storage |
ai.generate |
ai:invoke |
{ system, user, maxTokens? } |
see next section |
web.search |
web:search |
{ query, limit? } |
see next section |
Full example: examples/plugins/repo-health-page.
ai:invoke and web:search are available only on the page bridge. Declaring them does not add Worker context methods.
method: 'ai.generate',
args: {
system: 'Summarize this repository', // string, ≤ 2000
user: 'Public repository details', // non-empty, ≤ 8000
maxTokens: 500 // optional, 1–4000
}
// success: value is generated text, ≤ 65536 charactersThe Host checks permission, then shows the full system / user text, current provider name, model, and destination origin, noting that a configured backend proxy may relay the call. Declining sends nothing. With no provider configured the call fails; it does not fall back. Closing the page aborts in-flight requests.
method: 'web.search',
args: { query: 'open source alternatives', limit: 5 } // query ≤ 200; limit 1–10, default 5
// success: value: [{ title, url, snippet }]The user must configure a SearXNG HTTPS instance in Settings. Each search confirms the full query and instance URL. The Host rejects non-HTTPS, credentials, localhost / private networks / IPs; does not follow redirects; times out at 8 seconds; caps the response at 256 KiB. Only credential-free HTTPS result URLs are returned.
| Item | Limit |
|---|---|
| Manifest | 256 KiB |
| Package | 2000 files / 50 MiB, no symlinks |
| Repositories per call | 1000 |
| Action / processor result | 1 MiB |
| Exporter text | 5 MiB |
| Storage value / total | 64 KiB / 1 MiB |
| Storage key | 128 characters |
| Log file | 1 MiB (rotates to .log.1) |
| Worker call timeout | 5 seconds |
| Release assets / body | 500 / 256 KiB |
| Snapshot repos / releases | 10000 / 20000, 64 MiB total |
| Page concurrency / rate | 8 in flight / 120 per minute |
| Page resource | 20 MiB, allowlisted MIME |
| Host-download asset | 8 GiB |
| AI system / user / output | 2000 / 8000 / 65536 characters |
| Search query / count | 200 characters / 1–10 |
| Directory | Covers |
|---|---|
markdown-exporter |
Actions (card + bulk), processor, exporter, storage, clipboard |
smart-release-recommender |
Release processor, hostEnvironment, downloads:create
|
repo-health-page |
Page-only, repositories.search bridge |
- Arbitrary Node / Electron / raw IPC
- GitHub tokens, AI keys, cookies, auth headers
- Arbitrary filesystem, processes, or shell
- Arbitrary network or
network:<domain>(unimplemented in V1) - Direct Zustand store access
- Importing third-party React components into the main renderer
- A remote plugin store, auto-update, or silent install
- Auto-running installers
- A full security sandbox for malicious local Worker code