6.0 KiB
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:
NetworkFirstforGET /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. pushevent: payload is small JSON:Handler calls{ "notificationId": "...", "topicSlug": "homeassistant", "title": "...", "body": "...", "icon": "/icons/icon-192.png" }self.registration.showNotification(title, { body, icon, badge, data: { deepLink: "/topics/homeassistant/messages/<id>" }, tag: notificationId }).tagcollapses rapid duplicate pushes for the same notification into one OS notification.notificationclickevent:event.notification.close(), then either focus an existing client andpostMessage({ type: "navigate", path }), orclients.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
- User logs in (session cookie set).
- Client checks
navigator.serviceWorkersupport and registers the worker on app load (idempotent). - 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).
Notification.requestPermission()→ ifgranted, fetch the VAPID public key, callregistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }).POST /api/v1/push/subscribewith the resultingPushSubscriptionJSON → stored as apush_subscriptionsrow tied to the current user.- User can toggle push per topic from Topic Settings (
PATCH /api/v1/topics/:slug/subscription). 7–10. As described in01-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 CSSenv(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.