Files
2026-10-27 17:02:39 -06:00

4.4 KiB

Design note: PWA with Web Push

Decision

The companion is an installable web app (PWA) that gets notifications through Web Push. It is plain ES modules with no build step, served by Sandbar itself at /app/ or from any static host.

What it has to do

  1. Sign in to a Sandbar server.
  2. Follow topics and get push notifications while the app is closed.
  3. Acknowledge, snooze, escalate and resolve on-call alerts, ideally from the notification.
  4. Show monitors and Hammerhead tunnel status, read-only.

Options

PWA + Web Push Flutter or React Native Native (Kotlin, Swift)
Android push Yes, through the browser's push service FCM, needs a Firebase project FCM, same
iPhone push Yes since iOS 16.4, once added to the Home Screen APNs, needs a paid developer account Same
Server needs Standard Web Push (VAPID), no vendor account A Firebase or APNs sender, or a relay Same
Self-hosting fit Good: nothing leaves your server except the encrypted push Poor: FCM project keys, or a third-party relay Poor
Distribution A URL, "Add to Home Screen" Play Store, App Store, signing, review Same
Notification buttons Yes (Acknowledge, Snooze) on Android and desktop; limited on iOS Yes Yes
Background connection Push only, no always-on socket Same Same
Maintenance One small code base, one language A framework and two store pipelines Two code bases

Why a PWA

  • Sandbar is self-hosted and one binary. Native push needs either a Firebase project baked into a store app or a relay that sees every notification. Web Push needs neither: each browser vendor runs the push service, Sandbar signs with its own VAPID key, and the payload is encrypted end to end (RFC 8291), so the push service sees only ciphertext.
  • Nothing to publish. An admin points people at https://sandbar.example.com/app/. A store app would have to be a public, multi-tenant client with a server address field, built and signed by someone.
  • The sharks stack is web already. The Sandbar and Hammerhead UIs use the same tokens, so this reuses them.
  • The work is mostly API calls. Sandbar already has everything except push, so a thin client is enough.

What we give up

  • iPhone only gets push after Add to Home Screen, and notification action buttons are not shown there. Tapping the notification opens the app, where the buttons are one tap away.
  • Delivery is best effort, through Google, Apple or Mozilla. For a pager that is not enough on its own. Sandbar's escalation policies still email and repeat, and the existing ntfy-app path keeps working for people who want the ntfy app's persistent connection and Do Not Disturb override. The PWA is a companion, not a replacement.
  • No critical-alert bypass of Do Not Disturb. A native app could ask for that entitlement.
  • Background fetch is limited, so when the app is closed it only knows what pushes told it. It refreshes on open.

If those limits ever outweigh the benefit of skipping the stores, the same API (docs/mobile-api.md in Sandbar) serves a native client unchanged: the server side is one table of subscriptions and one push sender. A Flutter app would add FCM as a second sender.

How it fits together

phone (PWA)  --login-->  Sandbar  /api/v1/mobile/login  -> API key (bearer token)
phone        --subscribe-> Sandbar /api/v1/push/subscriptions {endpoint, keys, topics}
Sandbar Publish() --> push service (Google/Apple/Mozilla) --> service worker --> notification
notification button --> service worker --> POST /api/v1/alerts/{id}/ack  (bearer token from IndexedDB)
  • Sign-in returns an API key named after the device, not a cookie. It works from any origin, and signing out of one phone deletes only that key.
  • Service worker is a classic script (Firefox does not run module workers). It cannot read localStorage, so the sign-in is kept in IndexedDB.
  • Open app: while visible it streams each followed topic over /{topic}/json and suppresses the system notification.
  • Subscription rotation (pushsubscriptionchange) re-registers itself, and a 404 or 410 from the push service removes the server-side row.
  • Hammerhead status goes through Sandbar (/api/v1/hammerhead/edges) because Hammerhead lists edges only for signed-in people, not API keys. Sandbar holds that sign-in so the phone never gets Hammerhead credentials, and edge tokens are stripped.