Skip to content

Plugin Development EN

AmintaCCCP edited this page Oct 2, 2026 · 2 revisions

Plugin development

For authors of local plugins. The current API is apiVersion: "1" (V1 / V1.1 / V1.2 / V1.3 / V1.4 share this major). User guide: Plugin usage. 中文:插件开发.

Implementation references: PR #357 (V1–V1.3), PR #420 (V1.4 modal actions), 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. The community registry is informational only — not a store: the client never downloads, installs, or disables anything automatically.
  • 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 plain repositoryActions (without opensPage), repositoryProcessors, releaseProcessors, or exporters. Page-only plugins and plugins that only declare opensPage modal actions 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, opensPage placement is repository-card or bulk-toolbar. opensPage names a page declared by this plugin; clicking opens that page in a modal instead of starting the Worker (see V1.4)
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; also required by the page bridge's clipboard.write / clipboard.writeImage (V1.4)
external:open Lets an action open a credential-free HTTPS URL
downloads:create Shows Host download on a recommendation; also required by the page bridge's downloads.saveFile (V1.4). The user always picks the save location in a native dialog; 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, clipboard.* / downloads.* output methods, 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 allow-same-origin" and referrerPolicy="no-referrer". allow-same-origin keeps the page at the precise plugin-page://<plugin id> origin, which lets the Host reject documents swapped in by navigation; the page and the Host are still cross-origin and the page cannot read Host data. CSP is default-src 'none': scripts and styles load only from that plugin origin (no unsafe-inline, so dynamically inserted <style> elements are silently blocked — use an external stylesheet or a Constructable Stylesheet for runtime styles), images and fonts may also use data:; network (connect-src 'none'), nested frames, workers, and forms are all denied.

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, context? }  // context only for V1.4 modal actions, see below

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,
  origin: 'plugin-page://com.example.repo-health-page',
  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 window's origin is not fixed (localhost in development, file:// when packaged), so the page must post to '*' and include an origin field in the message. The Host checks event.source, event.origin === 'plugin-page://<plugin id>', that the message's origin field matches, pluginId, pageId, the one-shot token, and a field allowlist; messages from the Host to the page use that precise origin. 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 (V1.4's clipboard.writeImage / downloads.saveFile get 10 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. The last three rows are V1.4 system-output operations executed by the Host main process and available only on the page route (never on the Worker side).

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
clipboard.write clipboard:write { text }, ≤ 200,000 characters writes to the system clipboard
clipboard.writeImage clipboard:write { dataBase64 } (PNG) decodes and writes a clipboard image
downloads.saveFile downloads:create { fileName, dataBase64 } writes after the user confirms in a native dialog; see V1.4

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, ≤ 160000
  maxTokens: 500                        // optional, 1–4000
}
// success: value is generated text, ≤ 65536 characters

The 160,000-character budget (about 480 KB of UTF-8) exists so pages can put a full repository README into the prompt verbatim — see examples/plugins/repo-info-card. 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.

Modal actions and page output (V1.4)

repositoryActions gains opensPage: the action names a page declared by this plugin. When the user clicks it under the repository card's … → Plugin actions menu, the Host does not start the Worker; it opens the page inside a modal (sandboxed iframe, capability bridge identical to V1.2 / V1.3).

{
  "contributes": {
    "repositoryActions": [
      {
        "id": "generate-info-card",
        "title": "Generate repository info card",
        "placement": "repository-card",
        "opensPage": "info-card"
      }
    ],
    "pages": [
      { "id": "info-card", "title": "Repository Info Card", "entry": "ui/index.html" }
    ]
  }
}

Constraints:

  • opensPage requires placement: "repository-card" and must name a page id declared in this plugin's contributes.pages; referencing an unknown page rejects the install
  • A plugin whose actions are all opensPage needs no main and never starts a Node Worker; mixing with plain actions still requires main
  • The repository-contribution permission rule is unchanged: repositories:read or privateRepositories:read is still required

Modal repository context

When the Host opens the modal it attaches a one-shot context to plugin-page:init:

{
  type: 'plugin-page:init', pluginId, pageId, token,
  context: { repository: { /* see below */ }, readme: '…or null', language: 'zh' }
}

repository is a Host-side allowlist projection: id, name, full_name, description, html_url, stargazers_count, forks_count, language, created_at, updated_at, pushed_at, owner.login, topics, license, archived, disabled, fork, is_template, open_issues_count, default_branch, plus custom_description and ai_summary for card generation; Host-internal fields such as analysis errors, index timestamps, and avatar URLs are not included.

The context is delivered by the Host directly — it does not go through the capability bridge and is not subject to per-request confirmation; pages still reach Host capabilities only through the permission-checked bridge methods. The README is fetched asynchronously while the modal opens and is null on failure, so pages must work from metadata alone; once the README arrives the Host re-sends init with the same token, and the page should update its data without clobbering status notices the user has already seen.

Page output methods

clipboard.write, clipboard.writeImage, and downloads.saveFile are executed by the Host through injected hostOperations and exist only on the page route; the Worker-side capability router does not get them, so Workers cannot call the clipboard or save files.

  • clipboard.write: up to 200,000 characters of text
  • clipboard.writeImage: base64 PNG, decoded and written to the system clipboard
  • downloads.saveFile: the suggested file name is sanitized (path separators and control characters stripped, .png appended when the extension is missing), then written after the user confirms the location in a native save dialog (overwrite confirmation enabled); the Host returns the final file name to the page, does not touch the clipboard, and the plugin never learns the save path

The two binary methods get an args budget of 10 MiB (about 7.5 MiB of raw bytes), consistent between the renderer and the main process, so @2x screenshots are not rejected by the 1 MiB generic limit before reaching IPC; every other method stays at 1 MiB. fileName must not contain path separators or leading dots.

A full installable example: examples/plugins/repo-info-card — pick a style, aspect ratio, and language in the modal, call the user-configured AI (per-request confirmation) to generate the info-card HTML from a built-in layout skeleton, sanitize it and render into a shadow DOM, then copy the single-file HTML, copy a screenshot, or save a PNG; opened from Settings it falls back to repositories.search for picking a repository.

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
Page request payload 1 MiB (clipboard.writeImage / downloads.saveFile: 10 MiB)
Host-download asset 8 GiB
Clipboard text 200,000 characters
AI system / user / output 2000 / 160000 / 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
repo-info-card V1.4 modal action opensPage, init repository context and README, long-form ai.generate, clipboard.writeImage / downloads.saveFile

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