1/* eslint-disable no-undef */ 2// Dedicated Firebase Cloud Messaging service worker for DESKTOP Web Push. 3// 4// This is a SEPARATE worker from service-worker.js (the Workbox runtime-caching worker 5// registered by src/registerServiceWorker.js) â do NOT merge them. FCM's own 6// firebase.messaging().onBackgroundMessage() wiring needs to own this worker's 'push' event, 7// and the two workers are registered with different scopes (see NotificationsMixin.js 8// registerWebPush) so neither one's lifecycle (update/skipWaiting, cache versioning) interferes 9// with the other's. 10// 11// Plain (non-module) worker script â Firebase serves a "compat" build for exactly this, loaded 12// via importScripts since service workers can't use ES module imports in every browser we 13// support yet. 14importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js') 15importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js') 16 17// Kept in sync BY HAND with FIREBASE_WEB_CONFIG in src/constants.js â this file is served 18// statically (public/) and is not run through the webpack build, so it can't import that 19// module. appId is the Web app registered on 2026-09-02 in the gethello-firebase-auth project. 20firebase.initializeApp({ 21 apiKey: 'AIzaSyCh2DaT5NkHApW3f4rqKTc64Hs0Q6jzWrY', 22 authDomain: 'gethello-firebase-auth.firebaseapp.com', 23 projectId: 'gethello-firebase-auth', 24 storageBucket: 'gethello-firebase-auth.appspot.com', 25 messagingSenderId: '255570553355', 26 appId: '1:255570553355:web:98cc58e92589fba133f50e', 27}) 28 29// ORDER MATTERS. firebase.messaging() registers the SDK's own 'notificationclick' listener, which 30// calls event.stopImmediatePropagation() for notifications IT displayed â any listener added after 31// it never runs for those. Ours are registered first, so they always run, and they stop propagation 32// themselves once they have handled the click (else the SDK would open the link a second time). 33self.addEventListener('install', (event) => { self.skipWaiting(); event.waitUntil(diag('installed')) }) 34self.addEventListener('activate', (event) => { event.waitUntil(Promise.all([clients.claim(), diag('activated'), requestConfigFromClients()])) }) 35
36/** A new worker version may activate AFTER the page handed its config to the previous version; 37 * if that version predates config storage, Cache Storage holds nothing. Ask every open Kombo tab 38 * to hand it over again (page side: webPush.ts ensureConfigResponder). */ 39async function requestConfigFromClients() { 40 try { 41 const list = await clients.matchAll({ type: 'window', includeUncontrolled: true }) 42 list.forEach((c) => c.postMessage({ type: 'kombo-webpush-config-request' })) 43 await diag('config requested from clients', { clients: list.length }) 44 } catch (e) { 45 console.warn('[webpush-sw] config request failed', e) 46 } 47} 48self.addEventListener('push', (event) => event.waitUntil(diag('raw push event', event.data ? event.data.text().slice(0, 300) : '(no data)'))) 49 50// --------------------------------------------------------------------------------------------- 51// Delivery funnel callbacks (received / opened / dismissed). The worker has no session, so it 52// reports through the public ack endpoint whose capability is the (trackingId, deviceKey) pair 53// carried in the push payload â see NotificationController.publicAckWebDelivery. Single-device 54// sends put deviceKey in the payload; audience broadcasts don't, so the page hands the worker its 55// own deviceKey + API base once per load (message below), persisted in Cache Storage because a 56// worker's memory does not survive being killed between events. 57const CONFIG_CACHE = 'kombo-webpush-config' 58const CONFIG_URL = '/__kombo-webpush-config' 59// Worker-side diagnostics ring buffer, same cache. The worker's console is invisible to the page 60// (and to the /ifl log relay), so every step it takes is also appended here and the test page 61// (/webtools/webpush) reads it back via a 'kombo-webpush-diag-get' MessageChannel request. 62const DIAG_URL = '/__kombo-webpush-diag' 63const DIAG_MAX = 40 64const SW_VERSION = '2026-09-02.5' 65 66async function saveConfig(cfg) { 67 const cache = await caches.open(CONFIG_CACHE) 68 await cache.put(CONFIG_URL, new Response(JSON.stringify(cfg), { headers: { 'Content-Type': 'application/json' } })) 69} 70async function loadConfig() { 71 try { 72 const cache = await caches.open(CONFIG_CACHE) 73 const res = await cache.match(CONFIG_URL) 74 return res ? await res.json() : null 75 } catch (e) { 76 return null 77 } 78} 79async function loadDiag() { 80 try { 81 const cache = await caches.open(CONFIG_CACHE) 82 const res = await cache.match(DIAG_URL) 83 return res ? await res.json() : [] 84 } catch (e) { 85 return [] 86 } 87} 88/** Append one diagnostic line (also console-logged); never throws. */ 89async function diag(step, detail) { 90 const line = { at: new Date().toISOString(), v: SW_VERSION, step, detail: detail === undefined ? null : detail } 91 console.log('[webpush-sw]', step, detail === undefined ? '' : detail) 92 try { 93 const list = (await loadDiag()).concat([line]).slice(-DIAG_MAX) 94 const cache = await caches.open(CONFIG_CACHE) 95 await cache.put(DIAG_URL, new Response(JSON.stringify(list), { headers: { 'Content-Type': 'application/json' } })) 96 } catch (e) { 97 console.warn('[webpush-sw] diag store failed', e) 98 } 99} 100 101self.addEventListener('message', (event) => { 102 const msg = event.data || {} 103 if (msg.type === 'kombo-webpush-config') { 104 event.waitUntil(saveConfig({ apiBaseUrl: msg.apiBaseUrl, deviceKey: msg.deviceKey }).then( 105 () => diag('config stored', { apiBaseUrl: msg.apiBaseUrl, deviceKey: msg.deviceKey }), 106 (e) => diag('config store FAILED', String(e)), 107 )) 108 return 109 } 110 if (msg.type === 'kombo-webpush-diag-get') { 111 // Reply on the MessageChannel port the page handed us (event.ports[0]). 112 const port = event.ports && event.ports[0] 113 if (!port) return 114 event.waitUntil(Promise.all([loadConfig(), loadDiag()]).then( 115 ([config, lines]) => port.postMessage({ version: SW_VERSION, config, diag: lines }), 116 (e) => port.postMessage({ version: SW_VERSION, error: String(e) }), 117 )) 118 } 119}) 120 121/** Best-effort funnel report; never throws (a failed ack must not break click navigation). 122 * `data.revision` â present on an in-place-update push, see FirebaseService.kt / 123 * NotificationService.dataWithTracking â rides along as a number so the backend can tell an 124 * update's receive apart from the original send's (NotificationController.WebDeliveryAckDTO). */ 125async function ack(eventName, data) { 126 try { 127 const cfg = (await loadConfig()) || {} 128 const trackingId = data && data.trackingId
129 const deviceKey = (data && data.deviceKey) || cfg.deviceKey 130 if (!trackingId || !deviceKey || !cfg.apiBaseUrl) { 131 await diag('ack SKIPPED ' + eventName, { trackingId: !!trackingId, deviceKey: !!deviceKey, apiBaseUrl: cfg.apiBaseUrl || null }) 132 return 133 } 134 const revision = data && data.revision != null ? Number(data.revision) : undefined 135 // No `keepalive`: the callers wrap this in event.waitUntil(), which is the service-worker 136 // way of outliving the event (Chrome 152 does accept keepalive in a worker â verified 137 // headless on 2026-09-02 â it is simply redundant here). 138 const res = await fetch(cfg.apiBaseUrl + '/public/notifications/ack', { 139 method: 'POST', 140 headers: { 'Content-Type': 'application/json' }, 141 body: JSON.stringify({ trackingId, deviceKey, event: eventName, revision }), 142 }) 143 await diag('ack ' + eventName, { trackingId, revision: revision ?? null, status: res.status, apiBaseUrl: cfg.apiBaseUrl }) 144 } catch (e) { 145 await diag('ack FAILED ' + eventName, String(e && e.message || e)) 146 } 147} 148 149/** 150 * The Kombo data map of a displayed notification, whichever path displayed it: the SDK wraps the 151 * whole FCM payload under data.FCM_MSG (background, SDK-displayed); the page's foreground 152 * onMessage handler stores payload.data directly (see webPush.ts). 153 */ 154function komboData(notification) { 155 const d = (notification && notification.data) || {} 156 if (d.FCM_MSG) return (d.FCM_MSG.data) || {} 157 return d 158} 159 160/** Where a tap goes: explicit URL from the payload, else the link FCM resolved server-side 161 * (WebpushFcmOptions.link), else the app root. (redirectRoute is a ROUTER NAME the SPA resolves 162 * in NotificationsMixin â not resolvable here; those land on root.) */ 163function clickTarget(notification) { 164 const data = komboData(notification) 165 if (data.redirectURL) return data.redirectURL 166 const fcm = notification.data && notification.data.FCM_MSG 167 const link = fcm && fcm.fcmOptions && fcm.fcmOptions.link 168 if (link) return link 169 if (data.redirectRoute) console.warn('[webpush-sw] redirectRoute not resolvable in the worker, opening root:', data.redirectRoute) 170 return self.location.origin + '/' 171} 172 173/** 174 * Closes every currently-displayed notification matching a cancel push's target â used for the 175 * worker's own background handling below AND mirrored page-side (webPush.ts 176 * attachForegroundHandler) for the case the SDK forwards a data-only message straight to a 177 * visible tab instead of here. Matches by TAG first (tag IS collapseKey||trackingId at send time, 178 * see FirebaseService.kt WebpushNotification.setTag â cheap, no need to unwrap each 179 * notification's data), falling back to each shown notification's own komboData().trackingId for 180 * anything displayed with a different/no tag (e.g. an older SW version, or a send predating the 181 * collapseKey-in-data change). 182 */ 183async function closeCancelledNotifications(data) { 184 const tag = data && (data.collapseKey || data.trackingId) 185 const trackingId = data && data.trackingId 186 const shown = await self.registration.getNotifications() 187 const matches = shown.filter((n) => (tag && n.tag === tag) || (trackingId && komboData(n).trackingId === trackingId)) 188 matches.forEach((n) => n.close()) 189 await diag('cancel: closed notifications', { tag: tag || null, trackingId: trackingId || null, closed: matches.length, shown: shown.length }) 190 return matches.length 191} 192 193// Click: report the open, then focus a Kombo tab (navigating it to the target) or open one. 194self.addEventListener('notificationclick', (event) => { 195 if (event.action) return // action buttons are not ours 196 const data = komboData(event.notification) 197 const target = clickTarget(event.notification) 198 event.stopImmediatePropagation() // the SDK's handler must not also act on this click 199 event.notification.close() 200 event.waitUntil((async () => { 201 await diag('click', { trackingId: data.trackingId || null, target, tag: event.notification.tag || null }) 202 await ack('opened', data) 203 const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }) 204 const sameOrigin = clientList.find((c) => c.url && c.url.startsWith(self.location.origin)) 205 const internal = target.startsWith(self.location.origin)
206 if (sameOrigin && 'focus' in sameOrigin) { 207 await sameOrigin.focus() 208 if (!internal) return clients.openWindow(target) 209 if ('navigate' in sameOrigin) { 210 try { return await sameOrigin.navigate(target) } catch (e) { console.warn('[webpush-sw] navigate failed, opening window', e) } 211 } 212 return clients.openWindow(target) 213 } 214 if (clients.openWindow) return clients.openWindow(target) 215 })()) 216}) 217 218// Dismiss (swipe-away / close button): natively supported by the Web Notifications API â the one 219// platform where dismissals are observable without a foreground app. 220self.addEventListener('notificationclose', (event) => { 221 const data = komboData(event.notification) 222 event.waitUntil(diag('close', { trackingId: data.trackingId || null, tag: event.notification.tag || null }).then(() => ack('dismissed', data))) 223}) 224 225const messaging = firebase.messaging() 226 227// Background delivery. The SDK displays the notification ITSELF whenever the payload carries a 228// notification block (FirebaseService always sets one) â calling showNotification here again is 229// what produced duplicate banners. This handler is only the receipt: unlike the native shells 230// (foreground-only), the worker sees EVERY delivery. 231// 232// Cancel pushes (data.kombo === 'cancel', see NotificationService.cancelNotification) are 233// DATA-ONLY â no notification block, so the SDK never auto-displays anything for them â and are 234// handled here instead of the receipt path: close whatever's already shown for that 235// trackingId/collapseKey, then ack 'cancelled' instead of 'received'. NOTE: this branch only runs 236// when the worker actually gets the message â the SDK forwards data-only messages to any VISIBLE 237// tab instead of here, which is why webPush.ts's attachForegroundHandler mirrors this same logic. 238messaging.onBackgroundMessage((payload) => { 239 const data = payload && payload.data 240 if (data && data.kombo === 'cancel') { 241 return diag('onBackgroundMessage: cancel', { trackingId: data.trackingId || null }) 242 .then(() => closeCancelledNotifications(data)) 243 .then(() => ack('cancelled', data)) 244 } 245 return diag('onBackgroundMessage', { trackingId: data && data.trackingId || null, hasNotification: !!(payload && payload.notification) }) 246 .then(() => ack('received', data)) 247})
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.