Files
Notizarr/docs/phase1/05-pwa-web-push-design.md
T
2026-09-07 09:54:30 +00:00

130 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.