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

6.0 KiB
Raw Blame History

05 — PWA & Web Push Design

Manifest (manifest.webmanifest, generated via vite-plugin-pwa)

{
  "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:
    { "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.