// Service worker: shows push notifications and handles their buttons without opening the app. // Classic script, not a module: Firefox does not run module service workers. It reads the // sign-in that app.js saved in IndexedDB (see store.js) and calls the API directly. function kvGet(key) { return new Promise((resolve, reject) => { const r = indexedDB.open('sandbar-mobile', 1); r.onupgradeneeded = () => r.result.createObjectStore('kv'); r.onerror = () => reject(r.error); r.onsuccess = () => { const g = r.result.transaction('kv').objectStore('kv').get(key); g.onsuccess = () => resolve(g.result); g.onerror = () => reject(g.error); }; }); } async function api(session, method, path, body) { const res = await fetch(session.server + path, { method, headers: { Authorization: 'Bearer ' + session.token, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json().catch(() => null); if (!res.ok) throw new Error((data && data.error) || 'The server answered ' + res.status + '.'); return data; } function keyBytes(b64) { const pad = '='.repeat((4 - (b64.length % 4)) % 4); return Uint8Array.from(atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)); } const SHELL = 'sandbar-shell-v1'; const FILES = ['./', 'index.html', 'app.css', 'app.js', 'api.js', 'store.js', 'manifest.webmanifest', 'icon.svg']; self.addEventListener('install', (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); }); self.addEventListener('activate', (e) => { e.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim()), ); }); // The app shell loads from the cache first so it opens offline; API calls always go to the network. self.addEventListener('fetch', (e) => { const url = new URL(e.request.url); if (e.request.method !== 'GET' || url.origin !== self.location.origin || !url.pathname.startsWith(new URL(self.registration.scope).pathname)) return; e.respondWith( fetch(e.request) .then((res) => { if (res.ok) caches.open(SHELL).then((c) => c.put(e.request, res.clone())); return res; }) .catch(() => caches.match(e.request).then((r) => r || caches.match('./'))), ); }); self.addEventListener('push', (e) => { let p; try { p = e.data.json(); } catch { p = { title: 'Sandbar', message: e.data ? e.data.text() : '' }; } e.waitUntil(show(p)); }); async function show(p) { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const c of clients) c.postMessage({ type: 'push', payload: p }); const tag = p.alert || p.id || undefined; if (p.status === 'resolved' || p.status === 'acked') { // The alert moved on: take down its earlier notification, and say so quietly. for (const n of await self.registration.getNotifications({ tag })) n.close(); } if (clients.some((c) => c.visibilityState === 'visible')) return; // the open app shows it itself const firing = !!p.alert && p.status === 'firing'; const options = { body: p.message || '', tag, renotify: !!tag, data: { topic: p.topic, alert: p.alert, id: p.id }, requireInteraction: (p.priority || 3) >= 4, icon: 'icon-192.png', badge: 'icon-192.png', timestamp: (p.time || Date.now() / 1000) * 1000, actions: firing ? [{ action: 'ack', title: 'Acknowledge' }, { action: 'snooze', title: 'Snooze 30 min' }] : [], }; await self.registration.showNotification(p.title || 'Sandbar', options); } self.addEventListener('notificationclick', (e) => { const n = e.notification; const { alert } = n.data || {}; n.close(); e.waitUntil( (async () => { if (alert && (e.action === 'ack' || e.action === 'snooze')) { const s = await kvGet('session'); if (s) { try { if (e.action === 'ack') await api(s, 'POST', '/api/v1/alerts/' + alert + '/ack', {}); else await api(s, 'POST', '/api/v1/alerts/' + alert + '/snooze', { minutes: 30 }); return; } catch (err) { await self.registration.showNotification('Could not ' + (e.action === 'ack' ? 'acknowledge' : 'snooze') + ' the alert', { body: err.message + ' Open Sandbar to try again.', tag: 'err-' + alert, data: { alert }, }); return; } } } const target = new URL(alert ? '#alerts' : '#topics', self.registration.scope).href; const wins = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const win = wins.find((w) => w.url.startsWith(self.registration.scope)); if (win) { await win.focus(); win.postMessage({ type: 'open', alert, topic: (n.data || {}).topic }); } else { await self.clients.openWindow(target); } })(), ); }); // Browsers rotate push subscriptions now and then. Re-register the new one, or alerts silently stop. self.addEventListener('pushsubscriptionchange', (e) => { e.waitUntil( (async () => { const s = await kvGet('session'); if (!s) return; const { publicKey } = await api(s, 'GET', '/api/v1/push/key'); const sub = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(publicKey) }); const j = sub.toJSON(); await api(s, 'POST', '/api/v1/push/subscriptions', { endpoint: j.endpoint, keys: j.keys, topics: (await kvGet('topics')) || ['alerts'], device: s.device || 'phone', }); })(), ); });