Skip to content

Plugin Development EN

AmintaCCCP edited this page Sep 16, 2026 · 1 revision

Plugin development

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/.

Principles

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.Worker isolates 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.

Minimal layout

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.

Manifest

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" }
    ]
  }
}

Fields

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.

Permissions

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

Worker API

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.

activate(context)

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; get returns 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 }): query 1–200 chars; limit 1–100, default 20. Matches full_name, description, topics
  • getRepository / getRelease: null if missing
  • repository methods need repositories:read or privateRepositories:read; getRelease needs releases:read

The Worker has no ai.generate, web.search, generic fetch, filesystem, or download API.

Input shapes

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.

Return shapes

Unknown fields are rejected. Results must be JSON-serializable.

runAction

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' };
  • copy requires clipboard:write
  • open-external requires external:open and a credential-free HTTPS URL
  • no HTML, no arbitrary Host commands
  • 1 MiB max

runProcessor

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.

runExporter

return { content: '# Repositories', fileName: 'stars.md' };
  • content is plain text, 5 MiB max
  • fileName is optional and must not contain path separators. The Manifest fileExtension wins; the Host appends it if the returned name lacks that suffix
  • MIME type comes from the Manifest, not the return value

runReleaseProcessor

return {
  recommendedAssetId: 123,
  confidence: 0.92,
  reason: 'Windows x64 installer'
};
  • recommendedAssetId must belong to the current Release
  • confidence in [0, 1]
  • reason non-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

Pages (V1.2)

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.

postMessage protocol

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.

Page methods

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.

Advanced page capabilities (V1.3)

ai:invoke and web:search are available only on the page bridge. Declaring them does not add Worker context methods.

ai.generate

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 characters

The 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.

web.search

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.

Limits

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

Official examples

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

Explicitly not provided

  • 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