4.4 KiB
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
- Sign in to a Sandbar server.
- Follow topics and get push notifications while the app is closed.
- Acknowledge, snooze, escalate and resolve on-call alerts, ideally from the notification.
- 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}/jsonand 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.