Most widgets are read-only. Interactive widgets can also fire a named, pre-registered command on the Mac when the user taps — e.g. a soundbar widget that adjusts volume, or media controls.
This is not arbitrary code execution from a widget, by design. A widget can never define a command or ship code that runs on the Mac. It can only invoke a command declared ahead of time, against its own id, from a fixed vocabulary of capabilities — arguments arrive as data, never as shell text (see below). And an action never fires until you approve it on the Mac: registering one only proposes it. Actions ship in release builds — this is a consent boundary, not a debug flag.
The three stages
- Define (
register_action, or theactionsarray onpublish_widget) — declare a named, bounded capability against a widget. - Consent (Mac, one-time) — the capability sits pending until you approve it. Nothing is armed until you do.
- Invoke (
window.PREEN.trigger) — the widget fires it by id; the Mac checks the widget is on screen, consented, and under its rate limit before running it.
1. Define the action (backend)
register_action binds an actionId to a capability — a fixed vocabulary
the Mac interprets, not an opaque string:
| Capability | Shape | Runs |
|---|---|---|
launch-app | { "type": "launch-app", "app": "Music" } | open -a <app> |
run-script | { "type": "run-script", "path": "/usr/local/bin/volset.sh" } | that file on the Mac |
http-request | { "type": "http-request", "method": "POST", "url": "http://…" } | a bounded HTTP call (e.g. flip a smart light) |
raw-command | { "type": "raw-command", "command": "yamaha-ctl volset" } | advanced tier — an arbitrary shell command, flagged with a warning in the consent prompt |
Prefer the specific capabilities; reach for raw-command only when nothing else
fits.
// register_action
{
"widgetId": "wgt_abc123",
"actionId": "volset",
"capability": { "type": "raw-command", "command": "yamaha-ctl volset" },
"description": "Set soundbar volume",
"argKeys": ["vol"]
}
You can also declare actions inline when you first create the widget, via the
optional actions array on publish_widget — same shape as register_action,
minus the repeated widgetId.
2. Approve it (Mac, one-time)
Either way, the action is stored pending. The Mac surfaces a consent prompt listing the widget’s declared capabilities in plain language (e.g. “Run any command: yamaha-ctl volset” for the flagged advanced tier) — nothing fires until you approve. You can revoke consent at any time from the widget’s entry in the Mac app; deleting the widget revokes it too. Changing a widget’s declared actions later clears consent, so a changed capability always needs a fresh approval.
3. Fire it (frontend)
In the widget, call window.PREEN.trigger on tap:
button.addEventListener("click", () => window.PREEN.trigger("volset", { vol: 40 }));
window.PREEN.trigger("mutetoggle"); // no args
A widget can only fire actions registered against its own id, and only while it’s the widget on screen.
How arguments are passed (safely)
The invocation args are handed to the capability as a JSON string in the
$PREEN_ARGS environment variable — never interpolated into a command line.
So there’s no shell-injection surface: the capability (and its command, for
run-script / raw-command) is fixed at registration; only the JSON payload
varies.
# In a run-script/raw-command capability, parse $PREEN_ARGS — e.g. with jq:
VOL=$(echo "$PREEN_ARGS" | jq -r '.vol')
argKeys documents/validates which keys the action expects (["vol"] above).
trigger vs pulse
| Call | Use | Works in release? |
|---|---|---|
trigger(actionId, args?) | Run a registered, consented capability on the Mac | Yes — gated by consent, not build type |
pulse(detail?) | Side-effect-free echo (chirps this phone’s bird on the Mac) | Yes |
Gated by consent, not by build
Every layer here is enforced on the Mac, regardless of build type:
- Allowlist — a widget may only fire actions declared against its own id.
- Consent — the invoke route refuses to run anything for a widget you haven’t approved.
- On-screen check — an action only fires for the widget currently shown on the requesting phone.
- Rate limit — per-widget, to cap abuse from a misbehaving or compromised widget.
Neither trigger nor pulse is a network request — both post over a native
message-handler bridge, so the widget’s connect-src 'none' CSP is never
relaxed.