Writing plugins
A plugin is one JavaScript file in plugins/. It runs once when it loads, and registers hotkeys, launcher commands, menu bar items, timers, and event handlers. Macotron calls those when something happens.
macotron.plugin({
title: "Move Windows",
description: "Tile windows using hotkeys",
permissions: ["accessibility"],
});
macotron.keyboard.on("Tile Left", "ctrl+opt+left", () => {
const win = macotron.window.focused();
if (win) macotron.window.moveToFraction(win.id, { x: 0, y: 0, w: 0.5, h: 1 });
});
Anatomy of a plugin
Plugins are plain JavaScript on the QuickJS engine. There is no npm, no bundler, and no build step. Everything Macotron offers hangs off the macotron global. console, localStorage, setTimeout, alert, confirm, and prompt are globals too.
A typical file has three parts:
- The header. One
macotron.plugin({ … })call near the top, with a title, a description, the permissions the plugin needs, and its options. Settings → Plugins shows this. See Plugin settings. - Registrations. Calls such as
macotron.keyboard.on(),macotron.command(),macotron.menubar.status(),macotron.every(), andmacotron.on(). Make them at load time, at the top level of the file. - The first paint. A call at the bottom that draws the initial state, such as a menu bar item's first reading.
Macotron loads every .js file in plugins/ in alphabetical order. Each file runs in its own function scope, so a const opts in two plugins does not collide.
Hotkeys and launcher commands
A global hotkey works in any app. The first argument is its label in Settings, and the user can change the combination there.
macotron.keyboard.on("Hello", "cmd+shift+h", () => {
macotron.notify.toast("Hello", "From a Macotron plugin");
});
A launcher command is a row in the launcher. It can ask for arguments before it runs. Do not also bind a launcher command with keyboard.on; the user can give any command its own shortcut in Settings or with ⌘K in the launcher.
macotron.command("Generate Lorem Ipsum", "Placeholder text", (args) => {
macotron.clipboard.set(args.count + " " + args.unit);
}, {
id: "lorem-ipsum",
arguments: [
{ name: "count", type: "number", placeholder: "Count", default: 3 },
{ name: "unit", type: "dropdown", placeholder: "Unit", default: "paragraphs",
choices: [
{ title: "Words", value: "words" },
{ title: "Paragraphs", value: "paragraphs" },
],
},
],
});
Timers are macotron.every(ms, fn) to repeat and macotron.at("07:30", fn) to run once a day at a clock time. See macotron core for command, on, every, and at, and macotron.keyboard for hotkeys.
Save to reload
Macotron watches the workdir. What happens when you save a plugin depends on Hot Reload:
- Hot Reload off (the default). The new version does not run until you approve it with Review & Reload in the Macotron menu. Until then, the copy already in memory keeps running. This is a security feature; see Security.
- Hot Reload on. Plugins reload as soon as you save, with no review. The menu bar icon shows an orange reload badge while it is on.
Turn it on while you develop, with Enable Hot Reloading in the Macotron menu or Settings → General → Hot Reload. Turn it off again when you are done.
A reload reloads every plugin, not only the one that changed. Reload Plugins (⌘R in the Macotron menu) does the same by hand. Editing settings.json does not reload plugins, but changing a plugin's option in Settings does.
Check a plugin before it loads
The app binary has a dry-run mode. It loads a file in a test engine and prints OK or FAIL with the error, without touching the running app. Run it from the workdir:
/Applications/Macotron.app/Contents/MacOS/Macotron --check plugins/my-plugin.js
With no file names, it checks every plugin in your workdir. It exits with status 1 when any file fails.
For editor type checking, Macotron writes .cache/macotron.d.ts (the full typed API) and .cache/jsconfig.json into the workdir. Editors that use the TypeScript language service, such as VS Code, Cursor, and Zed, pick these up and flag wrong calls as you type.
Logging
A plugin runs with no console attached, so the log is the only record of what it did. Log the load, each command or hotkey and its result, and every caught error, with the values that decided the outcome.
console.log("weather: loaded, city =", opts.city);
console.log, info, and debug write at info level. console.warn writes at notice level and console.error at error level. macotron.log() is the same as console.log. Macotron tags each line with the plugin's file name. Do not log secrets, clipboard contents, or what the user typed.
To read the log, see Troubleshooting.
Async code and errors
Most host calls that wait on the system or the network return promises, so use async functions and await. A timer callback, an event handler, and the first call at the bottom of the file run unattended: if they throw, nobody sees it, and the menu bar keeps what it last drew. Wrap the body in try/catch and draw a final state on every path, failure included.
macotron.http does not reject when the network is down. It resolves with status: 0 and the reason in body. Check the status as well as catching.
async function refresh() {
try {
const res = await macotron.http.get(url, { timeout: 10000 });
if (res.status !== 200) throw new Error("HTTP " + res.status + ": " + res.body);
paint(JSON.parse(res.body));
} catch (e) {
console.error("weather: refresh failed", e);
macotron.menubar.status("weather", {
title: "--",
menu: [{ title: "Refresh failed: " + (e.message || e) }, "-", { title: "Refresh", onClick: refresh }],
});
}
}
macotron.every(15 * 60 * 1000, refresh);
refresh();
Run independent requests together with Promise.all. A loop of await runs them one at a time.
Macotron stops a plugin that runs for more than 5 seconds without returning, at load or in any callback. Long work belongs in awaited host calls, not in a busy loop.
Remembering state
Plugin variables do not survive a reload or a restart. Save what the user chose, such as a mode, a deadline, or a list they built, in localStorage: write it when it changes, and read it back at load. One store holds every plugin's keys, so prefix yours.
const KEY = "pomodoro.session";
let until = JSON.parse(localStorage.getItem(KEY) || "null");
function start(ms) {
until = Date.now() + ms;
localStorage.setItem(KEY, JSON.stringify(until));
}
Do not copy what is already saved elsewhere: option values from macotron.plugin(), or state you can ask the system for, such as volume or the current network. Host state a plugin held, such as a keep-awake assertion, is dropped on reload too, so claim it back at load from what you saved.
Store API keys and other secrets with a password option or macotron.keychain, never in localStorage or the source.
API version pragma
The plugin API has a version, macotron.version.api (currently 1.5.0). If your plugin uses a call added in a later version, say so in a comment in the first 20 lines:
// @macotron needs 1.5
Macotron loads the plugin only when its own API version is the same or higher. An older Macotron reports "Needs Macotron API 1.5" instead of failing partway. A file with no pragma counts as needs 1.0.
Built-in macOS only
A plugin must run on a stock Mac with only Macotron installed. Use macotron.* and tools that ship with macOS, such as /usr/bin/open and /usr/bin/defaults. Do not depend on Homebrew, npm, Python packages, or other installs.
See also
- macotron core and Globals
- Plugin settings
- Coding agents
- The built-in plugins on GitHub, as working examples