# 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/" }, 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. - ``, ``, `` for the home-screen icon. - `` 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.