Files
Remora/public/sw.js
T
2026-10-27 17:02:39 -06:00

140 lines
5.6 KiB
JavaScript

// 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',
});
})(),
);
});