Most widgets are read-only. Interactive widgets (decision D12) 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 you registered ahead of time, against its own id, with the command string fixed at registration (arguments arrive as data, never as shell text — see below). And the entire action runner is compiled out of release builds: a downloaded, notarized Preen does not execute widget-triggered commands at all. Actions exist only in local debug builds you run yourself; shipped Preen is read-only.
The two halves
1. Register the action (backend). register_action binds an actionId to a
shell command scoped to one widget:
// register_action
{
"widgetId": "wgt_abc123",
"actionId": "volset",
"command": "yamaha-ctl volset",
"description": "Set soundbar volume",
"argKeys": ["vol"]
}
2. 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.
How arguments are passed (safely)
The invocation args are handed to the command as a JSON string in the
$PREEN_ARGS environment variable — never interpolated into the command line.
So there’s no shell-injection surface: the command is fixed at registration; only
the JSON payload varies.
# In your command, 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 command on the Mac | No — action runner is debug-only |
pulse(detail?) | Side-effect-free echo (chirps this phone’s bird on the Mac) | Yes |
Debug-only by design
The action runner and register_action are compiled out of release builds.
Preen v1 ships read-only: a downloaded, notarized Preen app will not execute
widget-triggered commands. trigger simply does nothing there. Use actions when
running a debug build locally; use pulse for feedback that
must work everywhere.
Neither call is a network request — both post over a native message-handler
bridge, so the widget’s connect-src 'none' CSP is never relaxed.