# Writing plugins [Docs](README.md) · [Printers](printers.md) · [Cameras](cameras.md) · [Monitoring](monitoring.md) · [Notifications](notifications.md) · [Training frames](feedback.md) · [Hardware](hardware.md) · [Deployment](deployment.md) · [API & MCP](api.md) · [Plugins](plugins.md) · **Writing plugins** · [Architecture](architecture.md) · [Troubleshooting](troubleshooting.md)
A plugin is a folder with a manifest and up to three source files. There's no build step and nothing to minify, so what you publish is what people read. [Plugins](plugins.md) covers installing them and the sandbox they run in. - [A minimal plugin](#a-minimal-plugin) - [The manifest](#the-manifest) - [The three halves](#the-three-halves) - [The ctx API](#the-ctx-api) - [Events](#events) - [Nodes](#nodes) - [Effects](#effects) - [Credentials](#credentials) - [Talking to other plugins](#talking-to-other-plugins) - [Routes and gate](#routes-and-gate) - [Limits](#limits) - [Editor setup](#editor-setup) - [The shipped plugins](#the-shipped-plugins) - [Publishing](#publishing) ## A minimal plugin Two files make a plugin that lists your monitors in a panel of its own. ``` hello-monitors/ plugin.json plugin.js ``` ```json { "$schema": "https://raw.githubusercontent.com/oliverbravery/PrintGuard/main/plugins/plugin.schema.json", "id": "hello-monitors", "name": "Hello monitors", "version": "1.0.0", "permissions": ["state:read"], "reasons": { "state:read": "To list your monitors and whether each is watching." } } ``` ```js plugin.render((ctx) => ({ type: "col", children: (ctx.state.monitors || []).map((monitor) => ({ type: "row", children: [ { type: "text", value: monitor.name }, { type: "chip", value: monitor.watching ? "watching" : "idle", tone: monitor.watching ? "ok" : undefined }, ], })), })); ``` Zip the folder and install it with **Import a .zip** in the Plugins tab in Settings, then enable it. The zip holds one `plugin.json` with the plugin's files beside it, at the root or in the one folder that holds it. A second `plugin.json` or a file listed twice is refused. While you work, point PrintGuard at your repo instead and press **Update** as you push. ## The manifest `plugin.json` is a JSON object saying what the plugin is and everything it asks for. Only `id` and `version` are required, and a key PrintGuard does not recognise is dropped. ``` my-plugin/ plugin.json the manifest plugin.js draws a panel from nodes (optional) panel.html draws its own panel instead (optional) worker.js runs in the background (optional) alarm.mp3 anything it ships (optional) README.md its page in the catalogue (optional) icon.png shown beside its name (optional) shots/*.png its screenshots or GIFs (optional) ``` A plugin needs at least one of the three source files. ```json { "$schema": "https://raw.githubusercontent.com/oliverbravery/PrintGuard/main/plugins/plugin.schema.json", "id": "bed-clearance", "name": "Bed clearance", "version": "1.0.0", "description": "One line about what it does.", "author": "you", "homepage": "https://github.com/you/bed-clearance", "icon": "icon.png", "media": ["shots/panel.png", "shots/alert.gif"], "permissions": ["state:read", "notify", "sound", "net"], "reasons": { "state:read": "To see which monitors are printing.", "notify": "To tell you when the bed needs clearing.", "sound": "To play an alarm when it does.", "net": "To log each clearance with the service you use." }, "surfaces": ["panel"], "platforms": ["docker", "windows"], "assets": ["alarm.mp3"], "urls": ["https://api.example.com/v1/*"], "secrets": { "api_key": "The key from your account page" }, "events": ["alert", "http"], "tick_s": 300 } ``` | Key | Rule | |---|---| | `id` | Required. 3 to 40 lowercase letters, digits or hyphens, starting and ending with a letter or digit | | `version` | Required. Up to 32 letters, digits, underscores, dots, hyphens or plus signs | | `name` | Cut at 80 characters. Falls back to the id | | `description` | Cut at 400 characters | | `author` | Cut at 80 characters | | `homepage` | An `http` or `https` link | | `icon`, `media` | Image paths inside the folder, as `png`, `jpg`, `jpeg`, `webp`, `gif` or `svg`, in the case the file is named with. 8 media at most | | `permissions` | Names from the [permissions table](plugins.md#permissions) | | `reasons` | One line for every permission asked for, cut at 200 characters | | `surfaces` | `panel`, `monitor` or `settings`. Leaving it out means `panel` | | `platforms` | Where it runs. Leaving it out means everywhere | | `assets` | Files it ships beside its code | | `urls` | The only addresses it may reach. Needs `net` | | `secrets` | Up to 8 credentials the user fills in, each a name of 1 to 40 lowercase letters, digits, underscores or hyphens against a line saying what it is, cut at 200 characters | | `oauth` | A sign-in PrintGuard runs for it. Needs `oauth` | | `provides` | Up to 8 channels it answers other plugins on, each a name of 2 to 40 lowercase letters, digits or hyphens, starting and ending with a letter or digit, against a line saying what it answers. Needs `link:provide` | | `consumes` | Up to 16 `plugin-id:channel` names it calls. Needs `link:consume` | | `events` | The events that wake it | | `tick_s` | How often its worker runs anyway, 5 to 86400 seconds. Under 5 switches the timer off | A permission without a reason, `urls` without `net`, a [local address](#addresses) (a wildcard over a local suffix included) or a sign-in at one without `net:local`, `oauth` without the `oauth` permission, the `oauth` permission without `oauth`, or `provides` and `consumes` without their link permission each refuse the install. `oauth` takes these keys, and [credentials](#credentials) has how the sign-in runs. | Key | Rule | |---|---| | `authorize_url`, `token_url` | Required. Each one `https` address with a plain host and no wildcards | | `label` | The provider's name as the dashboard shows it, cut at 80 characters. Falls back to the host of `authorize_url` | | `register_url` | An `http` or `https` link to where the user registers their own app, cut at 200 characters | | `scopes` | Up to 20 | The manifest's own `homepage` is cut at 200 characters too. ### Reasons `reasons` is one line per permission, shown to whoever is deciding whether to enable it. Say what your plugin does with it, not what the permission is. ### Icon, media and README `icon`, `media` and a `README.md` are how a plugin presents itself. The icon sits beside its name, the media images open the plugin's page as a gallery, and the README renders under them the way GitHub renders it. For a repository install these files are read from the repository at the pinned commit. A zip carries them inside it. Either way they add nothing to what runs, which is why an SVG is allowed here and not in `assets`. The README is shown as Markdown and little else. Headings, paragraphs, lists, links, images, code, tables and blockquotes are kept, with `align` on a table cell or paragraph and `width` and `height` on an image. Any other HTML is dropped and its text kept, so forms, `
`, video, inline SVG, `style`, `class` and task-list checkboxes do not render. In a repository install, relative links and images resolve against the README's own folder at the pinned commit, and an image from outside the repository does not load. A zip's README has no address to resolve against, so its relative links go nowhere and only an image the manifest lists in `media` shows, in the gallery. ### Surfaces | Surface | Where it puts you | |---|---| | `panel` | A panel of its own on the dashboard | | `monitor` | Drawn on every monitor tile | | `settings` | Drawn in every monitor's settings, under its own heading | Anything that belongs to one monitor goes in `settings`. ### Platforms `platforms` says where it runs. The store filters the catalogue by the one you are on, and an install from a repository or a zip is refused on any other. | Platform | | |---|---| | `docker` | The self-hosted hub, on any image | | `docker-nvidia`, `docker-intel` | Only that image, for a plugin that needs the GPU it has | | `macos`, `windows` | The desktop app | Naming `docker` covers the images built from it, so declare a variant only when a plainer image would not do. ### Assets `assets` names the files it ships beside its code. They are hashed and pinned the same way. | Kind | Extensions | Used by | |---|---|---| | Images | `png`, `jpg`, `jpeg`, `webp`, `gif` | An `image` node, or a `panel.html` | | Audio | `mp3`, `ogg`, `wav` | `ctx.sound("alarm.mp3")` | | Text | `json`, `csv`, `txt` | `ctx.assets`, as a string | | Video | `mp4`, `webm` | A `panel.html` | A name is up to 40 lowercase letters, digits, dots, underscores or hyphens, with no folders. The type comes from the extension and the file has to start like the format it claims, so a script renamed to `.png` is refused. SVG is not on the list, since it is markup. ### Addresses `urls` lists the only addresses a plugin may reach, each a match pattern of `scheme://host/path`, the same grammar a browser extension uses. | Pattern | Reaches | |---|---| | `https://api.example.com/v1/*` | Anything under `/v1/` on that one host | | `https://*.example.com/*` | `example.com` and every subdomain of it | | `*://example.com/*` | That host over http or https | | `wss://hub.local:8123/api/*` | That endpoint over a WebSocket, on that port | | `*://*/*` | Anywhere at all, which is the widest thing you can ask for | A `*` scheme covers http and https, and `ws`, `wss`, `rtsp` and `rtsps` are named in full. A missing port means any port. An IPv6 address goes in brackets, as `http://[fd00::1]/*`. The scheme and host match in any case. The path matches as written, so `https://api.telegram.org/bot*/sendMessage` does not cover `/bot1/sendmessage`. A `*` in a path stands for any run of characters, `/` included. The query string is not part of the path. A pattern without a `?` covers its path with any query, so `https://api.telegram.org/bot*/sendMessage` covers `/bot1/sendMessage?chat_id=5` and not `/bot1/getUpdates?next=/sendMessage`. A pattern with a `?` matches what follows it against the query, as `https://example.com/search?q=*` does. A URL with a `.` or `..` segment in its path matches no pattern, percent-encoded or not. A pattern on this machine or the network around it needs `net:local` as well as `net`. That is any address that is not a public one, `localhost`, or a name ending `.local`, `.lan`, `.home`, `.home.arpa`, `.internal` or `.localhost`. A wildcard host counts, since it covers both, and so does a wildcard over one of those suffixes, such as `*.local`, or over the end of an IPv4 address, such as `*.168.1.50`. An address in any spelling a browser takes counts too, such as `127.1` or `2130706433`, and so does an IPv4 address written inside an IPv6 one, such as `[64:ff9b::c0a8:101]`. A multicast or site-local address counts as well, such as `224.0.0.1` or `[fec0::1]`. Without `net:local`, PrintGuard resolves a name once when a request or socket connects, refuses it if any address it resolves to is on this network and connects to an address it checked, so a public name pointing somewhere private gets nowhere, however often its answer changes. That connection never goes through a proxy named in the environment. A plugin can't call the hub's own API, with or without `net:local`. Every request, sign-in and socket PrintGuard makes for a plugin carries `X-PrintGuard-Plugin: 1`, and the hub answers `403` to anything that arrives with it. A request that sets that header itself is refused, as one that sets `Host` or `Connection` is. Read the dashboard with `state:read`, `camera:frames` and `history:read`. ## The three halves Each source file is one half of a plugin, and all three share one store. A `panel.html` reads the store as it was when the panel was drawn, so it doesn't see what the other two write after that. | | `plugin.js` | `panel.html` | `worker.js` | |---|---|---|---| | Runs in | A hidden iframe in the dashboard | A visible iframe in the dashboard | QuickJS in WebAssembly on the hub | | Runs while | A dashboard is open | A dashboard is open | The plugin is enabled | | Draws | A tree of nodes PrintGuard renders | Its own markup, styles and scripts | Nothing | | Registers with | `plugin` | `pg` | `plugin` | | `render`, `action` | Yes | No | No | | `on`, `serve` | Yes | `on` only | Yes | | `route`, `gate` | No | No | Yes | | Keeps top-level values | Until the dashboard reloads | Until the dashboard reloads, or the panel is redrawn, as toggling Customise does | Never, each call gets a fresh VM | `plugin.js` and `worker.js` each run inside a function with `plugin` in scope. `import` is a syntax error and there's no network. Treat `plugin.js` as having no DOM. Its iframe does have a `document`, but the frame is hidden and its policy allows no styles or images, so nothing put there is shown. The opaque origin refuses storage. The worker has no DOM at all. Neither frame has `fetch`, `WebSocket` or `RTCPeerConnection`, and a frame made inside one runs no script of its own. [What a browser still allows](plugins.md#what-a-browser-still-allows) lists what is left. The hub only lets its own pages and the origins in `PRINTGUARD_ORIGINS` put the dashboard and these two frames in a frame, so another site can't frame them. ### plugin.js `plugin.render` returns a tree of [nodes](#nodes). PrintGuard draws them with its own components, so a plugin matches the dashboard and inherits the user's theme. `render` runs when the state or store it is handed has changed, and after every action and every event it hooks, so keep it a plain function of the `ctx` it is handed. On the `monitor` and `settings` surfaces it is called once more per monitor, with `ctx.target` naming which and `ctx.surface` naming where. It runs whether or not there is a panel, and returning `null` draws nothing. `plugin.action` takes every press and choice, named by the node's `action`. ```js plugin.action((name, arg, ctx) => { if (name === "watch") ctx.command({ cmd: "monitor.update", id: arg, patch: { enabled: true } }); }); plugin.render((ctx) => ({ type: "col", children: (ctx.state.monitors || []).map((monitor) => ({ type: "row", children: [ { type: "text", value: monitor.name }, { type: "chip", value: monitor.enabled ? "watching" : "idle", tone: monitor.enabled ? "ok" : undefined }, { type: "button", label: "Watch", action: "watch", arg: monitor.id }, ], })), })); ``` That needs `state:read` and `monitor:control`. ### panel.html A node tree matches the dashboard, which is what most plugins want. Add a `panel.html` and you draw the panel yourself, with your own markup, styles and scripts. It replaces the node tree on the `panel` surface. ```html

0

``` It runs in an opaque origin with `connect-src 'none'`, so `pg` is the only way out. The `