-
Notifications
You must be signed in to change notification settings - Fork 4
Cultiva Plugins Guide
Audience: developers publishing plugins in CultivaPlugins and anyone extending the desktop (Electron) app.
Runtime model: the Cultiva client downloads manifests and files over HTTPS and installs them under the user profile (userData/cultiva-plugins). Theplugins/folder in the Cultiva or CultivaPlugins repo is for development & publishing only — the running app does not read your local repo path at runtime.
- Architecture at a glance
- Store repository layout
manifest.jsonreference- Entry script & sandbox lifecycle
contextAPI- Main-window UI bridge (Cultiva ≥ 0.4.0)
hooksAPI- Security & constraints
- Versioning & publishing
- Checklist & troubleshooting
flowchart LR
subgraph Store["CultivaPlugins (GitHub)"]
R[registry.json]
M[manifest.json + index.js + styles]
end
subgraph App["Cultiva Electron"]
PM[plugin-manager]
SH[PluginSandboxHost iframe]
MW[Main window DOM]
end
R -->|HTTPS| PM
PM -->|read / install| M
PM --> SH
SH <-->|postMessage| PM
PM -->|inject CSS / mount UI| MW
| Component | Role |
|---|---|
| Registry | JSON list of plugin ids, versions, baseUrl (raw GitHub URL to the plugin folder). |
| Manifest | Declares id, entry file, optional styles, minAppVersion, marketing fields. |
| Sandbox iframe | Opaque-origin iframe; plugin code is executed as the body of new Function('context','hooks', source). |
plugin-manager (renderer) |
Loads sandbox, wires RPC (storage, ui.showNotification), main-window sheet/header/garden bridge, injects CSS from manifest.styles. |
Each plugin is a top-level folder in the CultivaPlugins repo:
your-plugin-id/
manifest.json # required
index.js # required (or path in manifest.entry)
styles.css # optional; list in manifest.styles
The app fetches registry.json, resolves baseUrl, then downloads manifest.json, the entry script, and every file listed in manifest.styles.
| Field | Required | Description |
|---|---|---|
id |
yes | Lowercase plugin folder name; letters, digits, _, - only. |
name |
yes | Human-readable name (Settings → Plugins). |
version |
yes | SemVer string; must match the version you advertise in registry.json. |
description |
yes | Short summary for the store UI. |
icon |
yes | Emoji or short string shown in the list (can be empty "" if you prefer text-only). |
entry |
yes | Entry script filename (default index.js if omitted in older docs). |
styles |
no | Array of CSS paths relative to the plugin folder; injected into the main window <head>. |
minAppVersion |
strongly recommended | Lowest Cultiva version you tested. Use 0.4.0 if you depend on main-window UI. |
Minimal example
{
"id": "example",
"name": "Example",
"version": "1.0.0",
"description": "Demonstrates header + sheet.",
"icon": "✦",
"entry": "index.js",
"styles": ["styles.css"],
"minAppVersion": "0.4.0"
}The host wraps your file like this:
(function (context, hooks) {
/* YOUR PLUGIN SOURCE */
})(context, hooks);You must end the file by returning an instance (typically return new MyPlugin(context, hooks);).
| Method | When |
|---|---|
async onEnable() |
After the instance is constructed; use for registerHeaderItem, loading settings, timers. |
onDisable() |
Plugin unload / disable; clear intervals, release audio handles, etc. |
The renderer builds an instanceProxy that forwards INVOKE_INSTANCE into the sandbox. Method names are collected from the prototype chain of your instance, so ES class plugins behave the same as plain objects.
The header chip may call a known modal method on the proxy (e.g. openWeatherModal, openSettingsModal, openRadioModal, openModal) or fall back to the sandbox onClick handler from registerHeaderItem.
Parsed manifest.json object.
| Call | Semantics |
|---|---|
await context.storage.get(key) |
Per-plugin key/value (async). Keys are namespaced by the host. |
await context.storage.set(key, value) |
Persist a JSON-serializable value. |
| Method | Description |
|---|---|
registerHeaderItem({ label, icon, onClick? }) |
Registers a chip in the main window header. onClick runs inside the sandbox when the user activates the chip (unless a matching instance method handles the click first). |
registerGardenWidget({ position?, render, onTapMethod? }) |
Registers a garden widget. Inside render(relay), set relay.innerHTML = '...' or call relay.appendChild(node) (the host serializes outerHTML to the main document). Optional onTapMethod: string name of an instance method invoked in the main window when the user clicks the injected block (e.g. 'openWeatherModal'). |
updateGardenHtml(html) |
After registration, pushes new inner HTML for the same garden wrapper. |
showNotification(icon, text) |
Shows a toast in the main app (icon string first, then text). |
Plugin JavaScript cannot call document.querySelector on the Cultiva window — it only sees the sandbox document. Anything that must appear on top of the real app (modals, sheets, live header text) goes through the bridge below.
| Method | Purpose |
|---|---|
context.ui.openMainSheet(html) |
Mounts a modal sheet in the main window (position: fixed, full-screen dim + your markup). |
context.ui.closeMainSheet() |
Removes the sheet for your plugin. |
Markup contract: use data-* attributes so the host can delegate events without executing arbitrary <script> tags from your HTML (inline scripts in injected HTML are not a supported pattern).
| Method | Purpose |
|---|---|
context.ui.updateMainHeader({ label?, icon?, labelColor? }) |
Updates the header chip. Pass icon: '' for a text-only chip. Optional labelColor (CSS color) for dynamic styling (e.g. rainbow clock). |
The host forwards user interaction as MODAL_ACTION with (action, payload) to onModalAction on your plugin instance if you implement it.
Clicks — target element or ancestor with data-cultiva-act:
| Attribute | Behaviour |
|---|---|
data-cultiva-act="close" |
Closes the sheet (also Escape on the sheet root). |
data-cultiva-act="yourAction" |
Forwards yourAction with a merged payload: JSON from data-cultiva-payload, geographic fields data-lat / data-lon / data-city, data-tz, data-station, data-minutes, and optionally data-cultiva-collect="1" on a control (collects named fields inside the nearest .cultiva-sheet-card). |
change events — element with data-cultiva-change-act="name" → action === "name", payload includes value and relevant dataset fields.
input events — element with data-cultiva-input-act="search" → action === "input:search", payload { value }.
Implement:
async onModalAction(action, payload) {
if (action === 'close') { /* host already closed; optional cleanup */ return; }
if (action === 'save' && payload) { /* apply payload */ }
}Ship rules in manifest.styles for classes such as .cultiva-sheet-card, .cultiva-sheet-overlay, .cultiva-pill, etc., so your sheet matches Cultiva tokens (var(--bg-primary), var(--text-primary), …).
Subscribe with hooks.on(hookName, callback). Available hook names are defined by the host; common examples include:
onAppStartonHabitCompleteonSettingsChange
The sandbox registers interest via postMessage; the host invokes INVOKE_HOOK when events fire.
| Rule | Reason |
|---|---|
No window.electron in sandbox |
Prevents privileged renderer access. |
| No main-window DOM from sandbox | Prevents XSS / confused-deputy issues; use the bridge APIs. |
No fetch to file: |
Blocked by sandbox policy. |
CSS only via manifest.styles |
Keeps styling auditable and scoped to trusted file list. |
| Escape user-controlled strings in HTML you inject | Treat sheet HTML like any templated UI — encode or sanitize text. |
The host maintains a CSP and RPC allowlist (storage.get / storage.set / ui.showNotification). Do not rely on undocumented RPC channels.
- Bump
versioninmanifest.json. - Bump the same version for that plugin in
registry.json. - Push to
mainon CultivaPlugins.
Users install or update from Settings → Plugins; the client re-downloads files from baseUrl.
- Valid JSON in
manifest.json;entryfile exists and is UTF-8 (no BOM issues). - Final line:
return new YourPlugin(context, hooks); - No
window.electronor direct main-DOM access — useopenMainSheet,updateMainHeader, garden relay. - Styles listed in
manifest.stylesif you ship CSS. -
minAppVersionreflects the lowest Cultiva build you tested (0.4.0if you use the main-window bridge). -
registry.jsonbaseUrlpoints at raw GitHub paths for your folder.
| Symptom | Likely cause |
|---|---|
| Header chip never updates | Use updateMainHeader instead of querying DOM from sandbox. |
| Modal “does nothing” / invisible | You appended to sandbox document.body — use openMainSheet. |
| Garden empty | Use relay.innerHTML or appendChild on the relay; ensure registerGardenWidget ran. |
openWeatherModal not called from garden |
Pass onTapMethod: 'openWeatherModal' (or your method name) in registerGardenWidget. |
| Portable / Windows icon errors (app repo) | Ensure prebuild runs sync-build-icon.mjs so build/icon.ico includes 256×256. |
| Resource | URL |
|---|---|
| Cultiva (desktop) | https://github.com/krwg/Cultiva |
| CultivaPlugins (store) | https://github.com/krwg/CultivaPlugins |
| Latest Cultiva release | https://github.com/krwg/Cultiva/releases/latest |
This guide tracks the 0.4.0 plugin surface. When in doubt, inspect src/core/plugin-sandbox-host.js and src/core/plugin-manager.js in the Cultiva repo for the authoritative protocol.