130 lines
6.0 KiB
Markdown
130 lines
6.0 KiB
Markdown
# 05 — PWA & Web Push Design
|
||
|
||
## Manifest (`manifest.webmanifest`, generated via `vite-plugin-pwa`)
|
||
|
||
```json
|
||
{
|
||
"name": "Notizarr",
|
||
"short_name": "Notizarr",
|
||
"description": "Self-hosted notification hub",
|
||
"start_url": "/?source=pwa",
|
||
"scope": "/",
|
||
"display": "standalone",
|
||
"background_color": "#0b0d12",
|
||
"theme_color": "#0b0d12",
|
||
"orientation": "portrait",
|
||
"categories": ["utilities"],
|
||
"icons": [
|
||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||
]
|
||
}
|
||
```
|
||
|
||
`start_url` carries `?source=pwa` purely so the client can distinguish a
|
||
standalone launch from a browser tab for analytics/diagnostics — not used
|
||
for auth or routing logic.
|
||
|
||
## Service worker (Workbox via `vite-plugin-pwa`, `injectManifest` strategy — we need custom push/notificationclick handlers, so `generateSW` alone isn't enough)
|
||
|
||
- **Precache:** app shell (JS/CSS bundles, `index.html`, icon set) for
|
||
offline load of the UI shell.
|
||
- **Runtime caching:** `NetworkFirst` for `GET /api/v1/*` with a short
|
||
(e.g. 1 min) fallback cache, so a flaky connection still shows the last
|
||
known notification list instead of a blank screen. Mutating requests are
|
||
never cached and always network-only.
|
||
- **`push` event:** payload is small JSON:
|
||
```json
|
||
{ "notificationId": "...", "topicSlug": "homeassistant", "title": "...", "body": "...", "icon": "/icons/icon-192.png" }
|
||
```
|
||
Handler calls `self.registration.showNotification(title, { body, icon,
|
||
badge, data: { deepLink: "/topics/homeassistant/messages/<id>" }, tag:
|
||
notificationId })`. `tag` collapses rapid duplicate pushes for the same
|
||
notification into one OS notification.
|
||
- **`notificationclick` event:** `event.notification.close()`, then either
|
||
focus an existing client and `postMessage({ type: "navigate", path })`, or
|
||
`clients.openWindow(deepLink)` if none is open.
|
||
|
||
Payload is deliberately minimal (Web Push payloads are capped around 4 KB by
|
||
most push services) — the client fetches full notification detail
|
||
(`GET /api/v1/notifications/:id`) once it navigates, rather than trying to
|
||
cram the full message into the push payload. This also avoids storing
|
||
potentially sensitive message content in the push service's transit path
|
||
longer than necessary.
|
||
|
||
## VAPID
|
||
|
||
Generated once on first boot if `system_settings.vapid_keys` is absent
|
||
(using the `web-push` library's `generateVAPIDKeys()`), stored in Postgres —
|
||
durable via the existing `postgres/` volume, covered automatically by
|
||
`pg_dump` backups. `VAPID_SUBJECT` (a `mailto:` or `https:` contact) comes
|
||
from an env var. The public key is exposed via
|
||
`GET /api/v1/push/vapid-public-key` for the client to use with
|
||
`PushManager.subscribe({ applicationServerKey })`.
|
||
|
||
## Subscription flow
|
||
|
||
1. User logs in (session cookie set).
|
||
2. Client checks `navigator.serviceWorker` support and registers the worker
|
||
on app load (idempotent).
|
||
3. User taps "Enable notifications" (never auto-prompted on load — iOS
|
||
Safari requires this to be a direct user gesture, and prompting
|
||
unprompted is bad UX anyway).
|
||
4. `Notification.requestPermission()` → if `granted`, fetch the VAPID public
|
||
key, call `registration.pushManager.subscribe({ userVisibleOnly: true,
|
||
applicationServerKey })`.
|
||
5. `POST /api/v1/push/subscribe` with the resulting `PushSubscription` JSON
|
||
→ stored as a `push_subscriptions` row tied to the current user.
|
||
6. User can toggle push per topic from Topic Settings (`PATCH
|
||
/api/v1/topics/:slug/subscription`).
|
||
7–10. As described in `01-architecture.md`'s publish flow: new notification
|
||
→ stored always → push sent if the topic's per-user setting is on → tapping
|
||
the OS notification opens the deep link in the installed PWA (or a browser
|
||
tab, if not installed).
|
||
|
||
## iOS / Safari specifics
|
||
|
||
- Web Push on iOS requires **iOS 16.4+** and the PWA must be **installed to
|
||
the Home Screen** first — Safari does not support Push for ordinary
|
||
browser tabs. This is documented prominently in the in-app onboarding and
|
||
in the diagnostics screen (see below), since it's the single biggest
|
||
source of "why isn't it working" confusion.
|
||
- `<meta name="apple-mobile-web-app-capable" content="yes">`,
|
||
`<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`,
|
||
`<link rel="apple-touch-icon" ...>` for the home-screen icon.
|
||
- `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
|
||
plus CSS `env(safe-area-inset-*)` padding on the app shell, bottom nav,
|
||
and any fixed headers, so content clears the notch/Dynamic Island and the
|
||
home indicator.
|
||
- Standalone detection: `window.matchMedia('(display-mode: standalone)').matches
|
||
|| window.navigator.standalone === true`.
|
||
|
||
## Diagnostics screen (`/settings/diagnostics`)
|
||
|
||
Client-derived:
|
||
- PWA installed (standalone display-mode detected): yes/no
|
||
- Service worker: registered / active / not supported
|
||
- Notification permission: `Notification.permission` (granted/denied/default)
|
||
- Push subscription: `pushManager.getSubscription()` present/absent
|
||
|
||
Server-derived (`GET /api/v1/push/diagnostics`), scoped to the current
|
||
user's devices:
|
||
- Push endpoint status per device (active / disabled)
|
||
- Last successful delivery attempt (timestamp) per device
|
||
- Last failed delivery attempt (timestamp + sanitized reason) per device
|
||
- VAPID configuration status (configured / missing — admin-only detail also
|
||
surfaced on `/admin/push`)
|
||
|
||
The screen explicitly states what Web Push **cannot** guarantee (delivery
|
||
confirmation of on-screen display, iOS version/PWA-install prerequisites)
|
||
rather than implying certainty it can't back up.
|
||
|
||
## Deep-linking contract
|
||
|
||
Every push notification's `deepLink` is always
|
||
`${PUBLIC_BASE_URL}/topics/{topicSlug}/messages/{notificationId}`. The SPA
|
||
router resolves this route regardless of entry point (cold start from a
|
||
terminated PWA, warm start, or plain browser tab), fetching the notification
|
||
detail and marking it read on open.
|