thermograph/static/sw.js
Emi Griffith f34ab7ef96 Add PWA + Web Push delivery for weather alerts (#95)
Make the app installable and deliver existing alert notifications as OS
push, alongside the in-app bell.

Backend:
- PushSubscription model (per-device endpoint + keys, owned by a user) and
  register/unregister/test endpoints under /api/v2/push, cookie-auth scoped
  to the user like the subscription routes.
- push.py: VAPID key management (env -> data/vapid.json -> generated) and a
  pywebpush send helper that reports gone endpoints for pruning. No DB coupling.
- notify.py: after creating an in-app Notification, dispatch Web Push to the
  user's devices (guarded — a push failure never affects the in-app write;
  endpoints reported gone are pruned).
- Serve the .webmanifest with the correct media type.

Frontend:
- manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages.
- sw.js: push + notificationclick handlers (push-only; no fetch caching, so it
  doesn't fight the existing IndexedDB cache). Registered globally in nav.js in
  secure contexts.
- push-client.js + a "Notifications on this device" toggle and test-send on the
  /alerts page, subscribing through the existing cookie-aware apiFetch.

Push and service workers require a secure context, so this is active over HTTPS
(or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
2026-07-15 23:21:06 +00:00

54 lines
1.9 KiB
JavaScript

// Thermograph service worker — push delivery only.
//
// Deliberately NO fetch/offline caching: the app already has an app-level
// IndexedDB response cache (cache.js), and a caching service worker would fight
// it. This worker exists purely so the app is installable (a registered SW is a
// PWA prerequisite) and can receive Web Push while no tab is open.
//
// Served from {BASE}/sw.js, so its scope is {BASE}/ — it controls the whole app.
self.addEventListener("install", () => {
self.skipWaiting(); // activate this version immediately
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); // take control of open pages at once
});
// A push arrived. The payload is the JSON we send from push.py:
// { title, body, url, tag }.
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch (e) {
data = { title: "Thermograph", body: event.data ? event.data.text() : "" };
}
const title = data.title || "Thermograph";
const options = {
body: data.body || "",
icon: "logo-192.png",
badge: "logo-192.png",
tag: data.tag || "thermograph",
data: { url: data.url || "./" },
};
event.waitUntil(self.registration.showNotification(title, options));
});
// Tapping a notification: focus an existing app tab if one is open, else open the
// deep link the notification carried.
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || "./";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) {
client.navigate(target).catch(() => {});
return client.focus();
}
}
return self.clients.openWindow(target);
})
);
});