Skip to content

Latest commit

 

History

History
925 lines (741 loc) · 44.6 KB

plugin-development.md

File metadata and controls

925 lines (741 loc) · 44.6 KB

Writing plugins

Docs · Printers · Cameras · Monitoring · Notifications · Training frames · Hardware · Deployment · API & MCP · Plugins · Writing plugins · Architecture · Troubleshooting

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 covers installing them and the sandbox they run in.

A minimal plugin

Two files make a plugin that lists your monitors in a panel of its own.

hello-monitors/
  plugin.json
  plugin.js
{
  "$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."
  }
}
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.

{
  "$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
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 (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 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, <details>, 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 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. 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.

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.

<style>
  .risk { font-family: var(--font-display); color: var(--color-accent); font-size: 32px; }
</style>
<p class="risk" id="worst">0</p>
<video id="loop" autoplay muted loop></video>
<script>
  document.getElementById("loop").src = pg.asset("loop.mp4");
  pg.on("state", (state) => {
    const scores = (state.monitors || []).map((m) => (m.result ? m.result.score : 0));
    document.getElementById("worst").textContent = Math.max(0, ...scores).toFixed(2);
  });
</script>

It runs in an opaque origin with connect-src 'none', so pg is the only way out. The <video> needs the sound permission, since a panel without it can play no audio or video, a muted loop included.

A <script> runs wherever it sits in the markup. An inline handler such as onclick="..." is refused, so use addEventListener. A <script src> is refused too, as is import() of an address, so put the code inline. The frame cannot leave the page either: a link or a location change to another address stops the plugin with "sandbox navigated away".

On pg
pg.on("ready", fn) Called with the state once the panel is drawn
pg.on("state", fn) Called with the state on every change
pg.state The last state, for reading outside a handler
pg.secrets The names of the secrets the plugin holds, as a list. Never their values
pg.store Your own data. It saves when you assign the whole object, so pg.store = { ...pg.store, on: true }
pg.theme The dashboard's colours and fonts, by custom property name
pg.asset(name) A URL for a file you shipped, good inside your panel only. Audio and video from it play only with sound

The dashboard's colours and fonts are also set as the custom properties it uses itself, so var(--color-accent) is the accent the user picked. The background is transparent and the panel is as tall as it draws itself, up to 900px.

A panel can show a picture but not fetch one. Pull it through pg.http with binary: true and it arrives base64 encoded, ready to be a data: URL.

worker.js

worker.js runs without a UI. It wakes on the events its manifest lists, on its own timer and for requests to its routes. It gets a fresh VM each time, so anything it needs to remember goes in ctx.store.

plugin.on("alert", (event, ctx) => {
  ctx.store.alerts = (ctx.store.alerts || 0) + 1;
});

plugin.on("tick", (event, ctx) => ctx.log(`${ctx.store.alerts || 0} alerts so far`));

That needs alert in events, a tick_s and state:read, since an alert event only reaches a plugin that holds it.

A worker still busy with the last event is skipped, so a slow plugin drops events instead of falling behind. One that fails or runs past its limits is disabled and reported, and so is one whose answer is not the store and effects PrintGuard asked for, such as a worker that has redefined toJSON on a built-in prototype.

A worker has plugin, the JavaScript built-ins and QuickJS's own atob, btoa, performance, navigator and queueMicrotask in scope. There is no console or print, so log with ctx.log, and no timers or fetch. A call ends when your handler returns, so a promise or a queueMicrotask callback never runs and an import() never resolves.

A worker, a plugin.js and a panel.html each save only the keys they added, changed or deleted, and the hub applies them over whatever is stored when the write arrives. Two writers touching different keys don't undo each other, and on the same key the last write wins.

The ctx API

Every handler in plugin.js and worker.js gets a ctx. In a panel.html the same calls are on pg, checked against the same permissions.

On ctx Needs
ctx.state The state your permissions allow, refreshed each call
ctx.store Your own data. Assign to it and PrintGuard saves it
ctx.assets The text files you shipped, keyed by name. Not on pg
ctx.target, ctx.surface The monitor and surface being drawn, in render only
ctx.command(cmd) Ask PrintGuard to run an engine command The command's permission
ctx.http(request) Ask PrintGuard to make a request, to an address you declared net
ctx.socket({ url, tag }) Ask PrintGuard to hold a WebSocket open for you net
ctx.socketSend(tag, text) Write one text frame to a socket you opened
ctx.socketClose(tag) Close a socket you opened
ctx.call(request) Ask another plugin for something link:consume
ctx.publish(request) Publish on one of your own channels link:provide
ctx.notify(text) Show a message in the dashboard notify
ctx.sound(tones) Sound your own tones through the speakers, or name an audio asset sound
ctx.background(image) Put a picture behind the dashboard, or nothing to clear it background
ctx.log(text) Write a line to PrintGuard's log

ctx.state holds only what a permission names.

Permission Adds to ctx.state
state:read monitors, cameras and printers
printer:manage integrations, the printer integrations and their config forms
settings notifiers, the alert channels and their config forms

ctx.state.version is always there. plugin.d.ts types the fields, apart from the integrations and notifiers lists that printer:manage and settings add.

A plugin runs and returns, so nothing on ctx hands an answer back on the spot. A call that has one names it with a tag, and the answer arrives later as an event carrying that tag.

sequenceDiagram
    participant P as Plugin
    participant G as PrintGuard
    participant S as api.example.com
    P->>G: ctx.http({ url, tag: "now" })
    Note over P: handler returns
    G->>S: GET, if the address is declared
    S-->>G: 200
    G->>P: http event, tag "now"
Loading

Events

plugin.on(name, handler) hooks an event in plugin.js and worker.js, and pg.on(name, handler) does in a panel.html. An event reaches every half that hooks it, as long as the manifest's events names it. tick is the exception. It is the worker's own timer, set by tick_s, and needs no entry in events.

Event Fires Carries Needs
http An answer to one of your own ctx.http calls tag, status, body
socket A socket you opened coming up, carrying a frame, or ending tag, state, text
frame A still asked for with camera.snapshot camera_id, jpeg camera:frames
history A monitor's risk history, answering history.get monitor_id, now, buckets, alerts, stats history:read
call Another plugin asking on a channel you offer from, channel, body, call_id link:provide
answer The answer to one of your own ctx.calls tag, from, channel, body link:consume
message Something a plugin you named published from, channel, body link:consume
result Every inference on a watched monitor, capped at 5 per second per monitor monitor_id, camera_id, score, prediction, margin, ms, ts state:read
alert A defect held long enough to act on monitor_id, score, action, ts state:read
warning A watchdog condition, and its recovery monitor_id, message, recovered state:read
device A printer's status changed printer_id, status, progress, job, remaining_s, nozzle, bed state:read
error Anything the hub itself failed at, such as a missed alert. A command's failure is not passed on, since it can quote what its sender chose message state:read
state The full snapshot, once a second. plugin.js and panel.html also hear the one a command ends with Everything your permissions allow state:read
tick Your worker's own timer Nothing A tick_s

An event with a permission in the last column is dropped for a plugin that does not hold it. call, answer and message are added to events for you when the manifest has provides or consumes.

Each half keeps one handler per event name, so a second on for the same name replaces the first. A handler in plugin.js or worker.js gets (event, ctx), and one in a panel.html gets the event alone.

result is the one for "do something when the risk goes over x". It fires per inference with the raw score, before the monitor's threshold or streak logic.

plugin.on("result", (event, ctx) => {
  if (event.score < (ctx.store.limit || 0.8)) return;
  ctx.command({ cmd: "printer.action", id: ctx.store.printer, action: "pause" });
  ctx.notify(`${event.monitor_id} hit ${event.score}`);
});

That needs result in events, state:read, printer:control and notify, and it acts on a single frame. A monitor waits for a streak, so this will be twitchier. Count consecutive hits in ctx.store to match it.

Nodes

Node Fields
row, col children
text value, muted
chip value, tone: ok, warn, bad, accent
camera camera_id
image asset, label
float camera_id, label, value
button label, action, arg
select value, options, action, label
input value, action, label, kind: text or number, placeholder, secret
toggle on, action, label

A false or null child is dropped, which is how a node appears only once something else is switched on. A node of any other type is dropped too.

A button press calls action with the node's action name and arg, and a select change calls it with the option chosen. An input commits on blur or Enter, and a toggle hands you true or false. An input and a select draw their label above the field, so give them one.

camera and float need camera:view. A camera node is a placeholder PrintGuard fills with its own player, so the video never enters the sandbox.

A float node acts on the press itself, since a browser only floats a video for something the user did and a trip through the sandbox loses that. It draws nothing where the browser cannot float one, and the floating window shows the camera unadjusted, without the brightness, crop or rotation the dashboard draws.

Effects

Every call on ctx queues an effect. PrintGuard carries them out after the handler returns, checking each against the granted permissions first. One that fails the check is refused and the rest still run.

Commands

ctx.command runs an engine command, the same ones the dashboard sends. Each belongs to one permission, and a command in no permission is refused.

Permission Commands
monitor:control monitor.update
monitor:manage monitor.add, monitor.remove
camera:control camera.update
camera:manage camera.add, camera.remove, discover
camera:frames camera.snapshot
history:read history.get
printer:control printer.action
printer:manage printer.add, printer.update, printer.remove, printer.test, printer.cameras.refresh
settings settings.update, notify.test
tokens token.create, token.remove
alert:send notify.send

Architecture lists the protocol these belong to, and how printer.update, settings.update and the two tests treat a stored secret your plugin can't read.

Camera stills and risk history are asked for with a command and answered on an event.

plugin.on("tick", (event, ctx) => {
  for (const monitor of ctx.state.monitors || []) ctx.command({ cmd: "history.get", monitor_id: monitor.id });
  ctx.command({ cmd: "camera.snapshot", camera_id: "cam-1" });
});

plugin.on("history", (event, ctx) => { ctx.store.peak = event.stats.max; });
plugin.on("frame", (event, ctx) => { ctx.store.last = event.jpeg.length; });

history.get answers with the same rollups the monitor page draws. camera.snapshot hands over a base64 JPEG. The manifest needs history and frame in events.

Requests

ctx.http takes method, url, headers, json, tag and binary. The method defaults to GET, and json is the only body it sends.

plugin.on("tick", (event, ctx) => ctx.http({ url: "https://api.example.com/v1/now", tag: "now" }));

plugin.on("http", (event, ctx) => {
  if (event.tag === "now") ctx.store.latest = event.body;
});

A JSON answer arrives parsed. Anything else arrives as a string, and binary: true asks for it base64 encoded. The manifest needs http in events, or the answer never reaches you.

A redirect is not followed. Its 3xx status arrives as the answer, so ask for the address the service finally answers on.

A request with a Host header is refused. The host is the one in url.

A body over 256 KB fails the request, whether it is JSON, text or binary. The size is counted after decompression and before base64. PrintGuard asks for gzip or nothing, and an answer in any other encoding fails the same way. Nothing is cut short, so no http event arrives. The error shows in the dashboard that sent the request, so one from a worker goes to the log and not the dashboard.

An error from a request, a socket or a link to another plugin never repeats an address, a header or a name your plugin sent, since every plugin holding state:read hears it. A failed request says what kind of failure it was, such as ConnectTimeout, and the log has the rest.

Sockets

ctx.socket opens a WebSocket under a tag and socket events carry it, with state saying open, message or closed. PrintGuard drops it when the plugin is disabled, reinstalled or removed, when its worker fails or its route answers badly, or when it loses net or net:local. A plugin.js or panel.html that stops in the dashboard tells the hub nothing, so its sockets stay open until one of those. A socket still connecting at that moment is closed as soon as it opens. The manifest needs socket in events and a ws or wss pattern in urls.

A redirect is not followed here either. The socket fails to open, so declare the address the service finally answers on.

A socket that is sent more than 300 frames in 10 seconds is closed. Its closed event says why in text, which is empty for any other close, and an error event goes out alongside it. Subscribe to what the plugin needs if the service can send more than that. A dashboard that has 256 events waiting to be sent to it is disconnected and reconnects, so a frame it had not been sent by then never reaches that plugin.js.

plugin.on("tick", (event, ctx) => ctx.socket({ url: "wss://hub.local:8123/api/websocket", tag: "hub" }));

plugin.on("socket", (event, ctx) => {
  if (event.state === "open") ctx.socketSend("hub", JSON.stringify({ type: "ping" }));
  if (event.state === "message") ctx.store.last = event.text;
});

Opening a tag that is already open does nothing.

Notices, sound and the background

A worker has no screen and no speakers of its own, so its ctx.notify, ctx.sound and ctx.background are carried out by whichever dashboards are open. Nothing happens while none are.

ctx.sound takes a list of tones or the name of an audio asset, and a worker can name one as well as a plugin.js can.

plugin.on("alert", (event, ctx) => {
  ctx.sound([
    { hz: 880, ms: 1400 },
    { hz: 1320, ms: 1100, together: true },
  ]);
});

Call it from an event, since render runs again every second.

Each tone follows the one before unless it says together, and shape picks sine, square, sawtooth or triangle. It stays quiet until the user has pressed something in the page.

ctx.background takes a base64 data: URL of a PNG, JPEG, WebP or GIF and clears when passed anything else. The Glass theme frosts the panels over it.

Credentials

Declare a credential in secrets and PrintGuard draws the form, holds the value and fills it in as your requests leave.

"secrets": {
  "api_key": "The key from your account page"
}
ctx.http({ url: "https://api.example.com/v1/me", headers: { Authorization: "Bearer {{secret.api_key}}" }, tag: "me" });

The reference is all your code holds, in a header, a JSON body or the URL's path and query. A request naming a secret the user has not filled in is refused, and so is one with a reference in the URL's scheme, user or host. The URL is checked against your patterns again once it is filled in.

For a service with a sign-in, declare oauth and PrintGuard runs the authorisation code flow with PKCE and no client secret. The access token arrives as {{secret.oauth}} and is refreshed before it expires. It is the only part of a sign-in a request can refer to, so {{secret.oauth_refresh}} and {{secret.oauth_client_id}} are refused. When the provider refuses the refresh token the plugin is signed out, so pg.secrets stops listing oauth and its requests fail until the user signs in again.

"permissions": ["net", "oauth"],
"oauth": {
  "label": "Spotify",
  "authorize_url": "https://accounts.spotify.com/authorize",
  "token_url": "https://accounts.spotify.com/api/token",
  "register_url": "https://developer.spotify.com/dashboard",
  "scopes": ["user-read-playback-state"]
}

No client id goes in there, and one written in is dropped at install. A shipped id would be one app shared by everyone who installs the plugin, which is what providers hand out quota and terms against. Whoever installs it registers their own, and PrintGuard shows them the redirect URI to give the provider and links register_url.

authorize_url and token_url are each one https address with no wildcards and a plain host, which leaves out a login, a backslash and a percent-encoded or non-ASCII host, since a browser and the hub read those differently. An authorize_url may carry a query of its own, which is kept. Either one on this machine or the network around it needs net:local, and without it accepted the sign-in does not start. The consent dialog lists both addresses, and an update that changes either one signs its users out and has to be accepted again. A sign-in left open while an update changes token_url, or takes the permission away, is refused when the user comes back. A token endpoint that can't be reached, or answers with more than 64 KB, fails the sign-in with a page saying so.

Talking to other plugins

Plugins reach each other only where both sides said so and the user agreed. A plugin offering something declares the channels it answers on, and a plugin wanting them names the exact plugin and channel it will call. PrintGuard carries the message, and neither one sees the other's code or its store.

"permissions": ["link:provide"],
"provides": { "now-playing": "The track playing right now" }
plugin.serve((request, ctx) => ({ track: ctx.store.track, artist: ctx.store.artist }));

The other side names it in full, so spotify:now-playing is one channel of one plugin.

"permissions": ["link:consume"],
"consumes": ["spotify:now-playing"]
plugin.on("tick", (event, ctx) => ctx.call({ to: "spotify", channel: "now-playing", tag: "np" }));

plugin.on("answer", (event, ctx) => { ctx.store.track = event.body.track; });

To say something without being asked, publish instead. Every plugin that named the channel hears it as a message event.

plugin.on("tick", (event, ctx) => ctx.publish({ channel: "now-playing", body: { track: "Blue" } }));

Both sides show up in the consent dialog. A disabled plugin answers nobody, and a call nobody answers expires after 30 seconds. A call takes the first answer, so a second one to the same call, such as another open dashboard sends, is ignored.

Routes and gate

Both live in worker.js.

plugin.route answers everything under /plugins/<id>/ and needs routes.

plugin.route((request, ctx) => ({
  status: 200,
  type: "text/html",
  body: `<h1>${ctx.store.alerts || 0} alerts</h1>`,
}));
A request has
method GET, POST, PUT or DELETE
path, query The full path and the query as an object
headers cookie, authorization, accept, content-type, x-forwarded-for and user-agent, where present
body A string, cut at 64 KB
A response has
status 200 unless you say otherwise
type The content type, text/plain unless you say otherwise
body A string. It counts towards the 512 KB a worker may answer with, so a larger page disables the plugin
headers An object of strings, of which set-cookie, location and cache-control are kept and anything else is dropped

A status that is not a whole number from 100 to 599, or headers that are not an object of strings, stops the plugin. The request gets a 502 and the plugin is disabled and reported.

Every response goes out under a content security policy with sandbox allow-forms allow-scripts and frame-ancestors 'none'. The page gets an opaque origin, so it cannot act as the dashboard or be framed by it. The same policy keeps the page to what its own response carries.

A page a route serves
Scripts and styles Inline only. A <script src> or a stylesheet link is refused, your own routes included
Images, audio, video and fonts data: addresses, and blob: for all but fonts
fetch, XMLHttpRequest, WebSocket, sendBeacon Refused. A page talks to its worker by posting a form or following a link
Forms Post to the hub only, so to your own routes
Links and location Go anywhere. The browser does not stop a tab leaving

plugin.gate sees every other request to the hub and needs gate.

plugin.gate((request, ctx) => request.path.startsWith("/api/") || Boolean(ctx.store.session));
Gate rule
What refuses Anything but true, and a gate that throws or runs out of fuel or memory. The request gets a 403. A gate that fails is then disabled like any other plugin, and every request is refused until it is enabled again, reinstalled or removed, or the hub starts with PRINTGUARD_PLUGINS=off
What it sees The same request shape a route gets, with no body. WebSocket handshakes are asked about too, as a GET
Under load A request that waits more than 5 seconds for the gate to be free is refused on its own. The gate is not disabled for it
What stays open /api/health and the gating plugin's own pages, so uptime checks keep working and it can serve its own sign-in page
Caching An approval is cached for 10 seconds per method, path, query and set of the headers above, so a gate that decides by x-forwarded-for or user-agent is asked again for each. A refusal is never cached, so signing in takes effect at once

Limits

What Limit
Source file 256 KB each
Zip 12 MB. The dashboard refuses a larger file before sending it and the hub refuses one again, and stops reading a file at its limit however small the zip says it is. A compression other than stored or deflate is refused
Asset 4 MB each, 12 MB across a plugin. An install is refused at the file that passes either
README in a zip 64 KB. A larger one is left out, and so is an image over 4 MB, and the plugin installs without it
Media 8 images
Secrets 8, each value 4 KB
OAuth scopes 20
Channels 8 in provides, 16 in consumes
Store 16 KB. A write that changes nothing is not saved. Writes less than a second apart are saved together within a second, and other dashboards see them then
Body of a call, answer or publish 16 KB
Nesting Objects and lists 32 deep in the store, and in the JSON a ctx.http request sends, 31 deep in the body of a call, answer or publish. Anything deeper is refused
tick_s 5 to 86400 seconds, fired on a 5 second clock, so 7 means 10
Effects 32 per call. The rest are dropped
plugin.js call 4 seconds, then the plugin is stopped. That holds in Chrome. A plugin.js that never returns freezes the dashboard tab in Safari and the macOS desktop app, which both use WebKit, and Firefox is untested
Worker call 96 MB of memory and 400 million units of wasmtime fuel, then the plugin is disabled. A call that waits more than 5 seconds to start is dropped
Worker output 512 KB per call, the store, the effects and a route's response body together, then the plugin is disabled. So is one whose output is not an object carrying a list of effects
Node tree 400 nodes
Node text label 80 characters, action 60, placeholder 60
select options 60
panel.html height 900px
ctx.http 60 requests a minute per plugin, counting each socket it opens, 10 seconds each from the request leaving to the last of its answer. A refused request gets no http event. One refused for an address the manifest doesn't cover, a Host header or a secret before the path doesn't count towards the 60, and one refused for a blank secret or a failed sign-in does
ctx.http answer 256 KB once decompressed, whatever its type. A larger one fails the request and no http event arrives
Sockets 4 open per plugin, 64 KB per text frame sent, 256 KB per frame received, 10 seconds to open. One sent more than 300 frames in 10 seconds is closed
Sandbox start 8 seconds for plugin.js or panel.html to load
OAuth sign-in 10 minutes to finish it
notify.send 20 a minute across the hub, whoever sends them. One past that is refused
ctx.notify Cut at 200 characters
ctx.log from a worker Cut at 400 characters
ctx.sound 24 tones, 4 seconds, 20 to 12000 Hz
ctx.background 3 MB
Route request body Cut at 64 KB
Unanswered ctx.call Expires after 30 seconds, checked when the next call is made

Editor setup

The $schema key completes and checks the manifest as you type, in VS Code, JetBrains, Zed or anything else with a JSON language server. Nothing to install.

For the JavaScript, drop these two next to your plugin and any editor with TypeScript completes plugin, ctx and pg.

curl -O https://raw.githubusercontent.com/oliverbravery/PrintGuard/main/plugins/plugin.d.ts
curl -O https://raw.githubusercontent.com/oliverbravery/PrintGuard/main/plugins/jsconfig.json

plugin.d.ts documents the members, so the hover is the reference. It doesn't yet type binary on ctx.http. Without a jsconfig.json, // @ts-check at the top of a file does the same for that file.

The shipped plugins

The four in plugins/ are commented throughout, so copy the closest one.

Plugin Files Shows how to
picture-in-picture plugin.js Take the monitor surface and return one float node per monitor. It is the whole plugin in five lines
alert-sounds plugin.js Add a switch and a sound picker to each monitor's settings, watch each monitor's alert between renders and sound its own tones. Its main view returns nothing
progress-reports plugin.js, worker.js Share one store between two halves. The panel half adds a switch and an interval to each monitor's settings, and the worker counts result and alert events and sends the tally through notify.send on its own timer
spotify panel.html Draw its own panel, sign in with oauth, poll an API through pg.http, fetch the cover with binary: true and put it behind the dashboard

Publishing

Push the folder to a public repo and people can install it by name.

The Plugins tab reads a plugin's code against its manifest before anyone enables it, and says where a permission is asked for and never used, or used and never asked for. Clear those first.

For a review and a catalogue listing, open a pull request adding the folder under plugins/ in PrintGuard, then pin it.

cd web && npm install && cd ..
uv run python plugins/pin.py
pin.py
Checks first It runs the same code check over every plugin under plugins/, which is what needs npm install in web/
Refuses on disagreement A plugin whose code and manifest disagree stops the run. A command or address built at run time is printed and pinned anyway
Rewrites every entry plugins/catalogue.json is written whole, each plugin at the last commit that touched its folder

Commit first, since a pin describes bytes already in history, and run it again after every change or the plugin stops verifying.

An update that asks for more than the version someone accepted is switched off on their hub until they accept it again. Updates lists what counts as more.

An entry carries what the store shows and what a verified install has to match.

{
  "version": 1,
  "plugins": [
    {
      "id": "picture-in-picture",
      "name": "Picture in picture",
      "description": "Puts a button on every monitor that floats its camera above your other windows.",
      "author": "oliverbravery",
      "version": "1.0.0",
      "icon": "icon.png",
      "media": ["shots/monitor.png"],
      "permissions": ["state:read", "camera:view"],
      "surfaces": ["monitor"],
      "platforms": [],
      "repo": "oliverbravery/PrintGuard",
      "path": "plugins/picture-in-picture",
      "ref": "ef5c6ee564f905eb56292feb52ab000344dec287",
      "digests": {
        "plugin.json": "c6675a27abbf6c09fdbfabaff7dd8283ae2124796cfadccc44cc26dee9887d39",
        "plugin.js": "9625952af0f6765e172104bea611cf27eb48fed6034cea4a154ca98ac6ccf233"
      }
    }
  ]
}
Key
repo, path, ref Where the store installs it from, at a full commit SHA
digests The SHA-256 of the manifest in canonical form, then of every source file and asset. An install shows as verified when its id and all of these match

For your own catalogue, set catalogue_url to a JSON file of the same shape. Settings has no field for it and neither does the REST API, so it takes a settings.update command on the engine protocol.