-
Notifications
You must be signed in to change notification settings - Fork 179
Plugin Development EN
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/.
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. 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.
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 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.
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
|
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, clipboard.* / downloads.* output methods, 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 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.
On load the Host sends:
{ type: 'plugin-page:init', pluginId, pageId, token, context? } // context only for V1.4 modal actions, see belowPage 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).
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.
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, ≤ 160000
maxTokens: 500 // optional, 1–4000
}
// success: value is generated text, ≤ 65536 charactersThe 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.
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.
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:
-
opensPagerequiresplacement: "repository-card"and must name a page id declared in this plugin'scontributes.pages; referencing an unknown page rejects the install - A plugin whose actions are all
opensPageneeds nomainand never starts a Node Worker; mixing with plain actions still requiresmain - The repository-contribution permission rule is unchanged:
repositories:readorprivateRepositories:readis still required
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.
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,.pngappended 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.
| 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 |
| 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
|
- 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