Live Activity message

The per-device lock-screen pill updated in realtime via broadcast + APNs liveactivity push.

A per-device message you set from the dashboard (or raw REST, or MCP) that appears on the iOS Live Activity lock-screen pill / Dynamic Island, and updates in realtime — instantly while the app is foregrounded, and via push while it's backgrounded.

TL;DR

Set it fromDashboard device iOS App tab → Live Activity message; POST /rest/v1/device_live_activity_message; MCP set_live_activity_message.
Stored inpublic.device_live_activity_message (one row per device; message text).
Shown onThe Live Activity pill — BusymateActivityAttributes.ContentState.note. Only visible while the device is capturing (mode vpn/pac).
Foreground deliveryA broadcast trigger fires live-activity-message on device:<uuid>; iOS LiveActivityController applies it within ms.
Background deliveryA pg_net trigger calls push-notify with an APNs liveactivity content-state; iOS updates the pill with no app wake.
Clear itSet message to "" — the pill's note disappears.

Why two delivery paths

A Live Activity can be updated two ways: by the app while it's running (Activity.update), or by an APNs liveactivity push that iOS applies even when the app is suspended. We want the message to land whatever the device is doing, so a single write to device_live_activity_message.message fans out through two DB triggers — which means any writer (the dashboard, a raw PostgREST PATCH, or the MCP tool) gets the full realtime behaviour with no client-side push code:

  1. Foregroundbroadcast_live_activity_message → Realtime live-activity-message on device:<uuid> → iOS LiveActivityController.setMessage.
  2. Backgroundpush_live_activity_message (pg_net) → push-notify { push_type: liveactivity } → APNs → the system Live Activity (no app wake).

The pieces

Table + triggers (supabase/migrations/…_device_live_activity_message.sql)

  • device_live_activity_message(device_uuid PK, message, updated_at, updated_by). RLS: humans read/write any row; a device JWT reads its own row only (it consumes, never writes).
  • broadcast_live_activity_message() — clone of broadcast_settings_device; emits live-activity-message on device:<uuid>.
  • push_live_activity_message() — reads device_status for the current mode (online && source∈{vpn,pac} else off), then net.http_posts push-notify with aps.content-state = { mode, requestsPerSec: 0, note: message||null }.
  • Trigger auth: there's no Vault, so the function URL + service key live in a service-role-only live_activity_push_config row, seeded by push-notify on invocation via set_live_activity_push_config — the same convention the snapshots cron uses.

iOS (LiveActivityController.swift, RealtimeSubscriber.swift)

The pill's note was always rendered but never set, and every local state-build (VPN toggle, presence flip) passed note: nil. The fix is a stored currentMessage merged into every ContentState the controller hands to ActivityKit, so a local update can't wipe the operator's message:

  • RealtimeSubscriber handles the live-activity-message broadcast → LiveActivityController.setMessage.
  • fetchMessage (device-JWT read) runs on bootstrap() + resyncFromSystem() so a freshly-minted Activity — or a foreground after a background APNs message — shows the latest value instead of blanking it.
  • Background pushes update the system Activity directly; the next foreground resync re-reads the message and re-applies it.

Dashboard (DeviceIosAppTab.tsx)

A Live Activity message row (input + Send / Clear) under Connection type. It only writes the table row (the triggers do the fan-out — unlike the sheet rows, which dual-send a broadcast + push by hand), and reflects the current value in realtime by re-reading on the live-activity-message broadcast (so two open tabs + the device stay in sync, no polling).

MCP (set_live_activity_message)

set_live_activity_message(device_uuid|deviceName, message) upserts the row via the service client; message:"" clears. Mirrored in the /mcp, /ws, and /rest explorers, and covered by npm run test:e2e:mcp.

Notes & edge cases

  • Survives a VPN toggle — the merge re-injects currentMessage on every rebuild (this is the regression the iOS change exists to prevent).
  • Idle device — if the device isn't capturing there's no pill; the message is stored and shows the moment capture starts (or via the next push).
  • Multiple Live Activity tokens (e.g. a TestFlight + production install) all get the push — push-notify fans to every kind='liveactivity' token for the device.