SDK

The window.PREEN API

The single global the runtime injects before your widget loads — data in, and (for interactive widgets) signals out.

Before your widget’s HTML loads, the native app installs one global object, window.PREEN. It’s the entire interface between your widget and the hub. There is no other bridge — no network, no token, no imports.

Methods

MethodPurpose
onData(cb)Register your renderer. cb(payload) runs on feed delivery and replays the last non-null payload on registration. See delivery semantics below.
ready()Signal the UI is hydrated (flips the loading card to “live”). Optional — onData delivery calls it for you.
trigger(actionId, args?)Fire a pre-registered action (interactive widgets). args is an optional JSON object.
pulse(detail?)Chirp this phone’s bird on the Mac — each paired device has its own bird in the hub’s device strip. Side-effect-free; works in release builds.
haptic(kind)Play one system haptic on the phone itself. Never reaches the Mac; plays only while a finger is on the glass. Works in release builds.
hapticsAvailableProperty, not a method. true on iPhone, false on iPad. Outside the Preen app, the SDK itself may be absent.
dbThe widget’s own durable datastore on the Mac — db.put / get / update / delete / clear. Each returns a Promise. Works in release builds.
viaRelayProperty, not a method. true when the phone is reaching the Mac through the Preen Plus cloud relay instead of the local network. Always a boolean.

Signatures

// Data in
PREEN.onData(cb: (payload: any) => void): void
PREEN.ready(): void

// Signals out (post over the native bridge — not network)
PREEN.trigger(actionId: string, args?: object): void   // gated by user consent, not build type
PREEN.pulse(detail?: object): void                      // all builds

// Phone-local output (never reaches the Mac; see "Haptics")
type HapticKind =
  | "selection"                                     // detent tick — dials, pickers, sliders
  | "light" | "medium" | "heavy" | "rigid" | "soft" // impact — presses
  | "success" | "warning" | "error"                 // notification — outcomes
PREEN.haptic(kind: HapticKind): void
PREEN.hapticsAvailable: boolean                          // read-only

// Connection transport (read-only; see "Knowing how you're connected")
PREEN.viaRelay: boolean

// Per-widget datastore (durable SQLite on the Mac) — all return Promises
type Record = { id: number; ts: number; body: any }
PREEN.db.put(collection: string, body: object): Promise<{ id: number; ts: number }>
PREEN.db.get(collection: string, query?: { limit?: number; since?: number; order?: "asc" | "desc" }): Promise<Record[]>
PREEN.db.update(collection: string, id: number, body: object): Promise<{ id: number; ts: number }>
PREEN.db.delete(collection: string, id: number): Promise<{ deleted: number }>
PREEN.db.clear(collection: string): Promise<{ deleted: number }>

Datastore limits: collection names contain letters, numbers, _, or -, are nonempty, and are ≤64 chars (Unicode letters and numbers are accepted); get returns 100 rows by default (max 1000); 5,000 rows per collection.

Receiving data

onData is all most widgets need. The payload is exactly the JSON last pushed to the widget (via push_data or its data endpoint).

window.PREEN.onData((d) => {
  d = d || {};
  if (d.available === false) return; // a metric may be unavailable on some Macs
  render(d);
});
window.PREEN.ready();

Delivery semantics:

  • The callback receives a full payload, not a diff, delivered by the native app’s SSE stream and backup poll. The same snapshot can arrive more than once; rendering should be idempotent. Updates missed while disconnected are not replayed as an event history.
  • Registering onData replaces the previous callback and immediately replays the last payload if it is non-null. A live JSON null is delivered to an existing callback, but is not replayed on later registration.
  • Data is passed as a structured argument, never string-interpolated, so payloads can’t break out into code.

Sending signals out

trigger, pulse, and the db methods reach the Mac through native message-handler bridges. The native app makes the authenticated network requests, so the widget’s connect-src 'none' CSP still holds. haptic also crosses a native bridge but stays on the phone.

// Interactive widgets: invoke a named action registered against this widget.
button.addEventListener("click", () => window.PREEN.trigger("mutetoggle"));
window.PREEN.trigger("volset", { vol: 40 }); // with arguments

// Any widget: a calm echo back to the Mac (chirps this phone's bird there).
window.PREEN.pulse({ count: 3 });

trigger only fires actions registered against the widget’s own id, and only once that widget’s capabilities are approved on the Mac — see Interactive actions, including the Auto-consent controls option. The hub rejects unapproved actions. trigger returns void and does not report success, errors, command output, or HTTP response bodies to widget JavaScript. Use the data feed to show resulting state. pulse requires a connection but does not require action consent.

There is no arbitrary URL request method or general RPC bridge. Use db for supported request/response operations and registered actions for Mac-side effects. trigger itself is not touch-gated and does not show per-call confirmation prompts.

Haptics

haptic(kind) plays one system haptic on the phone. It’s the one call that never leaves the device: no Mac hop, no consent, nothing to register. The nine kinds map one-to-one onto the phone’s built-in feedback generators:

KindFeels likeUse it for
"selection"The picker-wheel detent. Built for rapid repetition.Dials, sliders, scrubbers — one tick per step
"light" "medium" "heavy" "rigid" "soft"A tap against something, in five weightsButton presses, toggles, drops
"success" "warning" "error"Two- and three-beat outcome patternsThe end of an action
// A press
button.addEventListener("click", () => window.PREEN.haptic("light"));

// A dial that ticks every 12° like a physical detent
let acc = 0, last = 0;
dial.addEventListener("pointermove", (e) => {
  if (!e.buttons) return;
  const a = angleFromCenter(e);
  acc += shortestDelta(a, last); last = a;
  while (Math.abs(acc) >= 12) {
    const step = Math.sign(acc);
    acc -= step * 12; value += step;
    window.PREEN.haptic("selection");
    render();
  }
});

Rules the phone enforces, so you don’t have to:

  • It plays only while a finger is on the glass (plus a beat after lift, so a release tick still lands). A phone on a stand will never buzz from a timer. If you need an untouched alert, that’s not what this is for.
  • The rate is capped well above any real dial; a runaway loop just drops ticks.
  • Unknown kinds are ignored, as is anything that isn’t a string.
  • The user’s System Haptics setting is honoured automatically.

iPad has no Taptic Engine. Every call is a silent no-op there, and PREEN.hapticsAvailable is false. The runtime mirrors that onto the DOM as a preen-haptics class on <html> and <body>, present only when haptics work — so a visual fallback is pure CSS:

.tick { opacity: 1; }                 /* visual detent, shown on iPad */
.preen-haptics .tick { opacity: 0; }  /* the phone can feel it instead */

Guard the call when a widget might open outside a paired display, where window.PREEN is absent: window.PREEN?.haptic("light").

Knowing how you’re connected

A display reaches its Mac one of two ways: directly over the local network, or — with Preen Plus — through the cloud relay, which is what keeps a widget live when the phone is away from home. One boolean tells you which:

if (window.PREEN.viaRelay) showAwayBadge();

Relay may also be used nearby when Local Network is off or Wi-Fi blocks direct communication. The app chooses and updates the route automatically.

The same state lands on the DOM as a preen-relay class on <html> and <body>, present only when relayed — so widgets that don’t care write nothing at all, and the ones that do can style it without a line of JS:

.badge { display: none; }
.preen-relay .badge { display: block; }   /* away, via the relay */

Notes:

  • The runtime injects the current connection state when it loads your page and keeps the boolean and class in sync as the transport changes.
  • It can change while your page is up, including when Preen returns from relay to a working local connection. When the route changes, the runtime keeps an unchanged widget loaded and updates the class on the existing page. CSS follows that change automatically. If your JS needs to follow it, read PREEN.viaRelay inside your existing render, not once at startup.
  • false also covers “nothing is connected behind this page” — a switcher pre-render, a preview, a standalone open. That’s the ordinary case, so it needs no special handling.
  • It’s informational only. Data arrives through onData identically on both paths, and the relay is end-to-end encrypted. But the relay is a double hop: if your widget can be calmer when it’s far from home (a slower sweep, fewer frames of animation), this is the signal to do it with.

Storing data

onData gives you a single, last-value feed pushed to you. When a widget needs to remember things across reloads — a high score, a history of runs, anything it computes itself — use its datastore, window.PREEN.db. Each widget gets its own SQLite database on the Mac; the calls are async and return Promises:

// Append a record, then read recent ones back.
await window.PREEN.db.put("sessions", { dur: 5400, end: Date.now() });
const recent = await window.PREEN.db.get("sessions", { limit: 30 });
const best = Math.max(0, ...recent.map((r) => r.body.dur));

Unlike localStorage (which is unreliable in the widget sandbox), the datastore is durable, queryable, and survives reloads. It works in release builds. See Widget datastore for the full API.

Reserved internals — don’t touch

The runtime uses these on the object; treat them as private: __cb, __last, __deliver, __readySent, __dbReq, __dbPending, __dbResolve, __setViaRelay, __applyViaRelay. Your widget should only ever call the public methods above.

Display surface context

window.PREEN.surface identifies the display host. PREEN.onSurface(callback) provides the current context immediately and on subsequent changes. On phones its kind is "display"; on a Mac Notch it is "mac-top-edge", with the Notch’s id, its bound displayID, mode, geometry, visibility, the current compact alert, and power (Mac battery availability, percentage, power-source and charging state). It is read-only context and grants no new native capability. See Mac top-edge surface for the full contract and the four authorable regions. __setSurface is reserved for the host.

On Mac Text Select, kind is "mac-text-select" and selection contains {id, text, appBundleId} for the current local session. The hovered widget receives this context independently of its shared data feed. Render text safely using textContent. Hovering renders the widget; actions and database writes require a click or keypress inside it.