Docs · Printers · Cameras · Monitoring · Notifications · Training frames · Hardware · Deployment · API & MCP · Plugins · Writing plugins · Architecture · Troubleshooting
Plugins are written in JavaScript and run in a sandbox. This page covers installing them and what they can reach. Writing plugins covers making one.
- What plugins are
- The shipped plugins
- Installing a plugin
- Verified and third party
- Enabling a plugin
- Updates
- Permissions
- What a plugin can and cannot do
- Credentials
- Switching plugins off at boot
- Writing a plugin
A plugin can draw a panel on your dashboard, run a job on the hub, or both. They get fine-grained permissions to an internal API, so you can add features without waiting on a release.
A panel joins the dashboard's layout, so it drags, pins and hides with the monitors. A plugin can also draw on every monitor tile or add its own heading to every monitor's settings.
Four plugins are in the catalogue, and their source is in plugins/.
| Plugin | What it does | Asks for |
|---|---|---|
| Picture in picture | Puts a button on every monitor that floats its camera above your other windows | state:read, camera:view |
| Alert sounds | Sounds a horn, a bell or an alarm when a defect is caught, on the monitors you pick | state:read, sound |
| Progress reports | Sends how far a print has got and how many defects it has seen, as often as you ask while the monitor is watching | state:read, alert:send |
| Spotify | Puts the current cover behind the dashboard, with the track and the transport in a panel | net, oauth, background |
The Plugins tab in Settings lists what you have installed and what the catalogue offers.
| From | How |
|---|---|
| The catalogue | Open a plugin's page for its screenshots, README and the permissions it will ask for, then install from there |
| A GitHub repository | Paste owner/repo, or owner/repo/path@branch for one inside a larger repo. A full https://github.com/owner/repo URL works too, and so does @tag or @sha in place of a branch |
| A file | Import a .zip of the plugin's folder, up to 12 MB, holding one plugin.json and the files beside it |
An install or update from a repository fails with GitHub's status if a plugin file can't be read, and only a 404 means the plugin has no such file.
Every installed plugin has the same page, opened from its card. A README is shown as Markdown only, so any HTML in it beyond text, links, images, tables and code is dropped.
The catalogue is filtered by where your hub runs, and an install from a repository or a zip is refused if the plugin names other platforms.
Verified means the manifest and every file hash to what the catalogue pins at a commit. With
the default catalogue these are the ones I have reviewed, and a hub whose catalogue_url
points elsewhere checks against that file instead. Anything else is third party, so read it
first. Both run under the same restrictions.
A zip that verifies is recorded as an install from the catalogue, so its page shows the README, icon and screenshots at the pinned commit and not the ones in the zip.
When you enable one, PrintGuard reads its code and shows in the same dialog where the code and the manifest disagree.
| It says | Meaning |
|---|---|
| Asks for a permission but never uses it | The manifest is wider than the code needs |
| Uses a permission without asking. PrintGuard will refuse it | The sandbox refuses it anyway, so this is early notice |
| Uses something, so its reach cannot be read from the code | It builds a command, an address or a channel as it runs |
It says what it found, it does not pass a verdict. A plugin that builds a URL as it runs is not a bad plugin, and the check that stops anything is the one at the sandbox edge.
A plugin arrives switched off. Enable lists what it asks for, what each permission allows and the author's reason for it. It is all or nothing. Disabling keeps what you accepted.
The list names the addresses a network permission covers, where a sign-in happens and where its tokens come from, and the other plugins it calls, so you see its whole reach before it runs. An update that asks for more brings up the same dialog.
A repository install pins the commit it resolved to. Update re-resolves the branch it was installed from, or the default branch if it had none, and re-checks the hashes.
An update that asks for more stands the plugin down until you accept the wider list. More means a permission, an address, another plugin it calls, a channel it answers other plugins on, a sign-in scope or a different sign-in address. A different sign-in address also signs the plugin out.
| Installed over | Grants, stored data and credentials |
|---|---|
| The same repository and path | Carry across |
| A zip over the zip it replaces | Stored data and credentials carry across when it asks for nothing more, and the permissions are asked again |
| Anywhere else | Start from scratch |
A plugin saved before 2.6.0 that lists an address on your own network, or signs in at one, without Reach your own
network stays installed with its data and credentials, off, and the hub warns at start that it now needs
that permission. Accept it on the plugin's page to turn it back on. One whose sign-in is plain
http is removed at start, since that sign-in can't be read any more, and the warning names it.
| Permission | Lets the plugin |
|---|---|
state:read |
Read monitor names, scores and alerts, and camera and printer status, and hear each score, alert, warning, printer update and error as it happens |
camera:view |
Put a live feed in its own panel |
sound |
Sound a short alert through the speakers, and play audio or video in its own panel |
monitor:control |
Enable, disable and retune any monitor |
printer:control |
Pause, resume and cancel prints |
notify |
Raise a message in the dashboard |
alert:send |
Send through whichever of your ntfy, Pushover, Telegram, Discord and desktop notification channels are set up |
net |
Reach the addresses its manifest lists |
net:local |
Reach addresses on this machine and the network around it, which covers private addresses, localhost, names ending .local, .lan, .home, .home.arpa, .internal or .localhost, a wildcard over one of those such as *.local or over the end of an address such as *.168.1.50, and an IPv4 address written inside an IPv6 one |
monitor:manage |
Add monitors and delete them |
camera:control |
Rename any camera and change its brightness, contrast, sharpness, crop, rotation and detection rate |
camera:manage |
Register cameras and delete them, and scan for ones not yet registered |
camera:frames |
Take a still of any camera and read the picture itself |
history:read |
Read a monitor's score history and past alerts |
printer:manage |
Connect, edit, test and delete printers, setting their credentials, refresh their webcams, and read which integrations exist |
settings |
Change alert channels, theme and the rest of Settings, send a test alert, and read which notifiers exist |
tokens |
Mint and revoke API tokens |
oauth |
Sign you in to a service and use the result |
link:provide |
Answer other plugins on the channels it offers |
link:consume |
Ask the plugins and channels it names, and hear them |
background |
Put a picture behind the dashboard, which the Glass theme shows through its see-through panels |
routes |
Answer requests under /plugins/<id>/, reading each request's headers |
gate |
See and refuse every other request to the hub. A yes is reused for 10 seconds for a request with the same method, path, query and headers it is shown |
Every permission a manifest asks for carries a line saying why, in the plugin author's own words, and one without a reason will not install. That line sits beside PrintGuard's own description of the permission when you are asked to accept it.
A plugin can start a scan (camera:manage), a printer test (printer:manage) or a test alert
(settings), but none of their answers reaches a plugin, so it never sees the result.
Storing its own data needs no permission. The store is capped at 16 KB and saved with your PrintGuard state.
A plugin gets the state its permissions allow and hands back what to draw and a list of things to do. PrintGuard does them, checking each against your permissions first.
flowchart LR
engine["PrintGuard"] -- "the state its permissions allow" --> plugin["Plugin in its sandbox"]
plugin -- "what to draw, things to do" --> check{"Granted?"}
check -- "yes" --> engine
check -- "no" --> refused["Refused"]
A plugin has up to three files, and each runs in a sandbox.
| File | Runs in |
|---|---|
plugin.js |
A hidden iframe in the dashboard, with an opaque origin and default-src 'none' |
panel.html |
A visible iframe with the same origin rules, where its own markup, styles and inline scripts are allowed |
worker.js |
QuickJS compiled to WebAssembly on the hub, under wasmtime |
The dashboard lets a frame load only from the hub and removes one that loads anything a second time, so a plugin that sends its frame elsewhere is stopped with "sandbox navigated away".
| Attack | What stops it |
|---|---|
| Take your credentials somewhere | Neither sandbox has sockets. The browser files' policy is connect-src 'none' and lets no script load from an address, WebRTC is removed from their frames, and the hub file has no WASI network and no filesystem. The only request out is one through PrintGuard, to addresses the plugin declared, apart from the three permissions listed under the table. A redirect is never followed, by a request or by a WebSocket, and without Reach your own network a name is resolved once and only connected to if every address it resolves to is a public one. What a browser still allows is below |
| Read your credentials at all | State is cut down to the fields a permission names. Printer configuration, notifier settings, MQTT credentials and API tokens are in no permission. The exceptions are routes and gate, which see the cookie and authorisation headers of the requests they answer |
| Read your camera frames | A camera in a plugin's panel is a placeholder PrintGuard fills with its own player, and the video never enters the sandbox. Reading the picture itself is camera:frames, which is its own thing to agree to, and a plugin's own pages are refused the live stream |
| Hang or exhaust the hub | The worker runs each call against a memory cap and a CPU budget, and a call that waits more than 5 seconds to start is dropped. A plugin that fails, or answers with anything but its data and a list of effects, is disabled and reported |
| Read the dashboard through the hub's own API | Every request, sign-in and socket made for a plugin carries an X-PrintGuard-Plugin header the plugin can't set or remove, and the hub answers 403 to anything that arrives with it, at any address, name or proxy. The video server beside it needs a login only the hub holds |
| Open the hub by breaking its own gate | A plugin holding gate that fails refuses every request until you enable it again, reinstall it or remove it. So does a running one the hub switches off because an update asks for more than you accepted, until you accept it or remove it, and one whose saved record can't be read at start, until you install it again or restart the hub |
| Do something it was not granted | Every command maps to a permission, checked at the sandbox edge before it goes anywhere |
| Pretend to be PrintGuard | A plugin.js has no styling and no markup of its own, and PrintGuard draws what it describes with its own components. A panel.html does draw itself, inside a panel carrying the plugin's name. A plugin's own pages are served into a sandboxed origin that is not the dashboard's |
| Change after review | The manifest and every source file are pinned by SHA-256 at a commit |
settings, printer:manage and camera:manage let a plugin name an address that PrintGuard then
contacts, by testing an alert channel, adding a printer or a camera, or repointing the catalogue.
They are the exception to the declared addresses, so each is highlighted when you are asked to
accept it.
The two frames and a plugin's own pages are held by the browser's own rules, and those rules leave three things open.
| Still possible | What it carries |
|---|---|
| A frame sends itself to another address on your hub | One request, to the hub. The dashboard removes the frame as soon as the page loads |
A frame asks Safari to connect ahead to a host, with <link rel="preconnect"> |
No request and no body. The hostname is the plugin's to choose, so a few bytes can leave in the lookup |
A page the plugin serves under /plugins/<id>/ sends your browser to another site, or opens a WebRTC connection |
Whatever the plugin's worker put in that page, in the address it goes to. The page loads nothing from another host and makes no request of its own, but it is a tab like any other, and no policy stops a tab leaving. routes is the permission that allows it |
The frame and page rules are tested in Chromium and in WebKit, which is Safari's engine. Firefox is not in the test run.
A plugin can set a credential and never read one back. Printer passwords, notifier keys and API
tokens go in and do not come out. A plugin with printer:manage or settings can test a saved
printer or alert channel with its saved key, at the address it was saved with.
Its own credentials work the same way. A plugin that needs a key shows a field for it on its page in the Plugins tab, once it's enabled. Paste the value there and PrintGuard holds it and fills it in as the plugin's requests leave.
Be clear on what that buys. The value never enters the sandbox, the plugin's stored data, the state the dashboard reads or a bug report. It does not stop a plugin you granted the network from sending a secret to an address it declared. Those addresses are in front of you before you enable it, the code check holds them against what it calls, and a listed plugin has been reviewed. That is the control.
A plugin that signs you in to a service, such as Spotify, needs an app of your own with that service. No plugin carries one, since a shared app is what providers hand out quota and terms against.
- Open the plugin's page and follow Create one to the service's developer page.
- Register an app there, giving it the redirect URI the page shows.
- Paste the app's client id into the page.
- Press Connect and sign in.
The redirect URI is the address you opened PrintGuard at with /oauth/callback on the end,
written as 127.0.0.1 since providers stopped accepting localhost. PrintGuard runs the
sign-in with PKCE, so there is no client secret to paste. Disconnect forgets the sign-in
and keeps the client id.
A plugin holding Authorise every request can lock you out. One that fails locks everyone out, since a hub with a broken gate refuses every request. So does one an update switched off until you accept what it now asks for.
To start the hub with plugins off, add PRINTGUARD_PLUGINS=off to its environment, then remove the plugin or enable it again.
Deployment has the compose snippet and what the routes and gate
permissions mean for an exposed hub.
A plugin is a folder with a manifest and up to three source files, with no build step. Writing plugins starts from a working one and covers the API, the limits and publishing to the catalogue.


