// Service Worker for MAESTRO. Two roles: // 1. PWA install eligibility (a fetch handler must be registered). // 2. V2 Web Push delivery — receives push events from the server and // either suppresses them when a visible tab already handled the // notification (ACK protocol) or shows an OS notification. // // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. const VERSION = 'v2'; const ACK_TIMEOUT_MS = 200; self.addEventListener('install', (event) => { self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener('fetch', (event) => { const req = event.request; if (req.method !== 'GET') return; const url = new URL(req.url); // Only handle same-origin /ui/ requests; let the network handle everything else. if (url.origin !== self.location.origin) return; if (!url.pathname.startsWith('/ui/')) return; // Network-first, fall back to cache only when offline. Never serve stale // HTML/JS by default — users always get the latest deploy when online. event.respondWith( fetch(req) .then((res) => { // Opportunistically cache successful responses for offline fallback. if (res.ok) { const clone = res.clone(); caches.open(VERSION).then((cache) => cache.put(req, clone)).catch(() => {}); } return res; }) .catch(() => caches.match(req).then((hit) => hit || Response.error())) ); }); // ── Push notification handler ─────────────────────────────────────────── // Strategy: when one or more open tabs exist, broadcast a "notify-request" // and wait up to ACK_TIMEOUT_MS for any visible tab to ACK. ACK means the // page is showing the user state changes in-app, so we suppress the OS // notification. No ACK → tab is hidden or closed (race conditions included) // → we show the OS notification. This is more robust than visibilityState // polling because the page decides whether IT will handle the user-facing // surfacing. self.addEventListener('push', (event) => { if (!event.data) return; let payload; try { payload = event.data.json(); } catch { payload = { title: 'MAESTRO', body: event.data.text(), tag: 'maestro' }; } const showOptions = { body: payload.body ?? '', tag: payload.tag, icon: payload.icon ?? '/ui/icon-192.png', badge: '/ui/icon-192.png', data: payload.data ?? {}, }; event.waitUntil((async () => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true, }); if (clients.length === 0) { await self.registration.showNotification(payload.title ?? 'MAESTRO', showOptions); return; } const handled = await waitForAck(clients, payload); if (!handled) { await self.registration.showNotification(payload.title ?? 'MAESTRO', showOptions); } })()); }); function waitForAck(clients, payload) { return new Promise((resolve) => { let settled = false; const handler = (e) => { if (settled) return; if (e.data && e.data.type === 'notification-handled' && e.data.tag === payload.tag) { settled = true; self.removeEventListener('message', handler); resolve(true); } }; self.addEventListener('message', handler); setTimeout(() => { if (settled) return; settled = true; self.removeEventListener('message', handler); resolve(false); }, ACK_TIMEOUT_MS); for (const client of clients) { client.postMessage({ type: 'notify-request', tag: payload.tag, payload }); } }); } // ── Notification click handler ────────────────────────────────────────── // Focus an existing /ui/ tab if present (and tell it which task to open), // otherwise open a new one at /ui/?task=N. self.addEventListener('notificationclick', (event) => { event.notification.close(); const taskId = event.notification.data && event.notification.data.taskId; const targetUrl = taskId ? `/ui/?task=${taskId}` : '/ui/'; event.waitUntil((async () => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true, }); for (const client of clients) { if (client.url.includes('/ui/')) { try { await client.focus(); } catch { // some browsers throw if focus is not allowed — fall through } client.postMessage({ type: 'open-task', taskId }); return; } } await self.clients.openWindow(targetUrl); })()); });