1/* eslint-disable no-restricted-globals -- a service worker's global IS `self`; CRA's config bans it for window code */ 2/* CellCog service worker (CEL-2450, 2026-09-09; one-sound rule + tap tracking CEL-2490, same evening): Web Push ONLY. 3 No caching and no fetch interception: the app keeps loading exactly as before. This file turns a push message 4 into a system notification and opens (or focuses) the chat when the notification is tapped. 5 Payload shape (services/web_push_service.py): { title, body, url, tag, silent }. 6 EVERY push shows a notification, or is skipped only because the person is looking at that very page. Chrome budgets 7 pushes that show nothing and then shows its own "This site has been updated in the background" card; the 8 cross-device clear that did exactly that was removed 2026-09-09. Never add a push branch that shows nothing. */ 9 10self.addEventListener('install', () => self.skipWaiting()); 11self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); 12 13const TAP_PARAM = 'src'; 14const TAP_VALUE = 'push'; 15const TAP_MESSAGE_TYPE = 'cc-push-tapped'; 16 17const samePath = (clientUrl, targetPath) => { 18 try { return new URL(clientUrl).pathname === targetPath; } catch (e) { return false; } 19}; 20const kindFor = (path) => (path.indexOf('/ai_employee/') === 0 ? 'employee' : 'chat'); 21const withTapParam = (href) => { 22 const u = new URL(href); 23 u.searchParams.set(TAP_PARAM, TAP_VALUE); 24 return u.href; 25}; 26 27self.addEventListener('push', (event) => { 28 let data = {}; 29 try { 30 data = event.data ? event.data.json() : {}; 31 } catch (e) { 32 data = { title: 'CellCog', body: event.data ? event.data.text() : '' }; 33 } 34 const title = data.title || 'CellCog'; 35 const url = data.url || '/'; 36 const targetPath = new URL(url, self.location.origin).pathname; 37 const baseOptions = { 38 body: data.body || '', 39 icon: '/logo192.png', 40 badge: '/logo192.png', 41 // Dedupe (Sep 9 2026): one notification per tag (chat id, or employee id for AI employees); a newer one REPLACES 42 // the older, so cards never stack. renotify stays TRUE (Nitish): a card the person never dismissed can sit in the 43 // center for hours, and a new "needs you" must still alert; each real stop is one alert, never a pile. 44 tag: data.tag || 'cellcog', 45 renotify: true, 46 data: { url }, 47 }; 48 event.waitUntil( 49 self.clients.matchAll({ type: 'window', includeUncontrolled: true }) 50 .then((clients) => { 51 // A tab that is FOCUSED and already on the target page is the person looking at it: no notification. 52 const viewing = clients.some((c) => c.focused && c.visibilityState === 'visible' && samePath(c.url, targetPath)); 53 if (viewing) return null; 54 // One sound per device (journey review Sep 9 2026, gap 4): while ANY cellcog.ai tab is open, that tab owns 55 // the audio (the leader tab rings, honoring the mute), so the banner is silent. With no tab open the banner 56 // carries the sound, unless the account is muted (payload.silent, "Mute all sound notifications"). 57 const silent = clients.length > 0 ? true : !!data.silent; 58 return self.registration.showNotification(title, Object.assign({}, baseOptions, { silent })); 59 }) 60 .catch(() => self.registration.showNotification(title, baseOptions)) 61 ); 62}); 63 64self.addEventListener('notificationclick', (event) => { 65 event.notification.close(); 66 const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href; 67 const targetPath = new URL(target).pathname; 68 const kind = kindFor(targetPath); 69 // Sep 9 2026 tap bug: a tab this worker does NOT control (the page loaded before the worker registered) makes 70 // client.navigate() reject, and the old chain swallowed that rejection: tap did nothing. Order now: focus a tab 71 // already on the target PATH (query string ignored) and tell it about the tap; else try navigating a controlled 72 // tab; on ANY failure, or no tab at all, open a new window. openWindow needs no control over anything. A page that 73 // is opened or navigated learns about the tap from ?src=push (tracking, hooks/usePushAccountSync). 74 event.waitUntil( 75 self.clients.matchAll({ type: 'window', includeUncontrolled: true }) 76 .then((clients) => { 77 const onTarget = clients.find((c) => samePath(c.url, targetPath));
78 if (onTarget && 'focus' in onTarget) { 79 return onTarget.focus().then((focused) => { 80 try { (focused || onTarget).postMessage({ type: TAP_MESSAGE_TYPE, kind }); } catch (e) { /* tracking only */ } 81 return focused; 82 }); 83 } 84 const navigable = clients.find((c) => 'navigate' in c && 'focus' in c); 85 if (navigable) { 86 return navigable.navigate(withTapParam(target)) 87 .then((c) => ((c || navigable).focus ? (c || navigable).focus() : null)) 88 .catch(() => self.clients.openWindow(withTapParam(target))); 89 } 90 return self.clients.openWindow(withTapParam(target)); 91 }) 92 .catch(() => self.clients.openWindow(withTapParam(target))) 93 ); 94}); 95 96// The push service can rotate a subscription (new endpoint). Keep this browser subscribed with the same server key; 97// the app registers the new endpoint under the signed-in account on its next load (utils/webPush.ensureSubscribed) and 98// the old row prunes itself on the push service's 410. 99self.addEventListener('pushsubscriptionchange', (event) => { 100 const previous = event.oldSubscription || event.newSubscription; 101 const key = previous && previous.options && previous.options.applicationServerKey; 102 if (!key) return; 103 event.waitUntil( 104 self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).catch(() => null) 105 ); 106});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.