PageSourceSearch

https://www.gala.fr/firebase-messaging-sw.js

js gala.fr collected 2026-10-02 04:00:18 UTC 29,327 bytes, 671 lines download raw bytes

1// Give the service worker access to Firebase Messaging.
2// Service workers require the compat version (modular SDK doesn't support importScripts)
3importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
4importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
5
6const SW_ENV = 'prod';
7const CURRENT_SITE = 'Gala';
8const PUSH_HOST = 'https\u003A\/\/push.lefigaro.fr';
9
10// ⚠️ CRITICAL: Install and activate listeners for immediate service worker updates
11// Without these, old versions may continue running until the browser forces an update
12self.addEventListener('install', (event) => {
13    event.waitUntil(self.skipWaiting());
14});
15
16self.addEventListener('activate', (event) => {
17    // Claim all clients immediately so new version takes control
18    // Also clean up old data if needed
19    event.waitUntil(
20        self.clients.claim().then(() => {
21            if (typeof log === 'function') {
22                log('Service worker activated and claimed clients');
23            }
24        })
25    );
26});
27
28// ⚠️ CRITICAL: Define notificationclick BEFORE Firebase initialization
29// Firebase may otherwise overwrite custom notification click behavior
30// Handle notification click events
31self.addEventListener('notificationclick', event => {
32    event.notification.close();
33
34    event.waitUntil((async () => {
35        try {
36            const { data: jsonData = '{}' } = event.notification;
37            const data = JSON.parse(jsonData);
38            const { push_id: pushId = null, deviceId: dataDeviceId = null } = data;
39
40            // Fall back to IndexedDB when deviceId is not present in notification data
41            // (service workers cannot access localStorage directly)
42            const deviceId = dataDeviceId ?? await getDeviceIdFromDB();
43
44            const tasks = [];
45
46            if (pushId) {
47                tasks.push(reportPushOpen(pushId, deviceId));
48            }
49
50            if (data.url) {
51                tasks.push(handleNotificationClick(data.url));
52            }
53
54            if (tasks.length > 0) {
55                await Promise.all(tasks);
56            }
57        } catch (error) {
58            // Log will be available after helper functions are defined
59            if (typeof log === 'function') {
60                log('Notification clicking failed.', { error }, 'warn');
61            }
62        }
63    })());
64});
65
66self.addEventListener('notificationclose', event => {
67    if (typeof log === 'function') {
68        log('Notification closed.', event);
69    }
70});
71
72// Notification permission state (updated via postMessage from main thread)
73const NOTIFICATIONS_GRANTED = 'granted';
74const PERMISSION_DB_NAME = 'firebase-notification-permissions';
75const PERMISSION_STORE_NAME = 'permissions';
76const PERMISSION_KEY = 'app-permission-state';
77const DEVICE_ID_DB_KEY = 'device-id';
78let cachedNotificationPermission = null; // Will be set by main thread via postMessage
79
80// In-memory deduplication of push_ids to prevent showing the same notification twice
81// (e.g. backend retry after a failed FCM delivery, or duplicate FCM dispatch)
82const processedPushIds = new Set();
83const PUSH_ID_TTL_MS = 60 * 1000; // 60s — well beyond any realistic retry window
84
85/**
86 * Opens (or creates) the IndexedDB database used to persist notification permission state
87 * across service worker restarts and browser closes.
88 *
89 * @returns {Promise<IDBDatabase>} The opened database instance
90 */
91async function openPermissionDB() {
92    return new Promise((resolve, reject) => {
93        const request = indexedDB.open(PERMISSION_DB_NAME, 1);
94
95        request.onerror = () => reject(request.error);
96        request.onsuccess = () => resolve(request.result);
97
98        request.onupgradeneeded = (event) => {
99            const db = event.target.result;
100            if (!db.objectStoreNames.contains(PERMISSION_STORE_NAME)) {
101                db.createObjectStore(PERMISSION_STORE_NAME);
102                if (typeof log === 'function') {
103                    log('IndexedDB created for permission storage.', { dbName: PERMISSION_DB_NAME });
104                }
105            }
106        };
107    });
108}
109
110/**
111 * Persists a value to IndexedDB under the given key so it survives service worker restarts.
112 *
113 * @param   {string}
113        key   - The key to store the value under
114 * @param   {string}        value - The value to persist
115 *
116 * @returns {Promise<void>}
117 */
118async function saveValueToDB(key, value) {
119    let db;
120    try {
121        db = await openPermissionDB();
122        const transaction = db.transaction([PERMISSION_STORE_NAME], 'readwrite');
123        const store = transaction.objectStore(PERMISSION_STORE_NAME);
124        store.put(value, key);
125
126        return new Promise((resolve, reject) => {
127            transaction.oncomplete = () => {
128                db.close();
129                if (typeof log === 'function') {
130                    log('Value saved to IndexedDB.', { key, value });
131                }
132                resolve();
133            };
134            transaction.onerror = () => {
135                db.close();
136                reject(transaction.error);
137            };
138            transaction.onabort = () => {
139                db.close();
140                reject(new Error('Transaction aborted'));
141            };
142        });
143    } catch (error) {
144        if (db) {
145            db.close();
146        }
147        if (typeof log === 'function') {
148            log('Failed to save value to IndexedDB', { key, error }, 'warn');
149        }
150        throw error;
151    }
152}
153
154/**
155 * Retrieves a persisted value from IndexedDB by key.
156 * Returns null if no value has been stored yet.
157 *
158 * @param   {string}              key - The key to retrieve
159 *
160 * @returns {Promise<string|null>}    The stored value, or null if not found
161 */
162async function getValueFromDB(key) {
163    let db;
164    try {
165        db = await openPermissionDB();
166        const transaction = db.transaction([PERMISSION_STORE_NAME], 'readonly');
167        const store = transaction.objectStore(PERMISSION_STORE_NAME);
168        const request = store.get(key);
169
170        return new Promise((resolve, reject) => {
171            let result;
172
173            request.onsuccess = () => {
174                result = request.result || null;
175            };
176
177            request.onerror = () => {
178                reject(request.error);
179            };
180
181            transaction.oncomplete = () => {
182                db.close();
183                if (typeof log === 'function') {
184                    log('Value retrieved from IndexedDB.', { key, value: result });
185                }
186                resolve(result);
187            };
188
189            transaction.onerror = () => {
190                db.close();
191                reject(transaction.error);
192            };
193
194            transaction.onabort = () => {
195                db.close();
196                reject(new Error('Transaction aborted'));
197            };
198        });
199    } catch (error) {
200        if (db) {
201            db.close();
202        }
203        if (typeof log === 'function') {
204            log('Failed to read value from IndexedDB', { key, error }, 'warn');
205        }
206        return null;
207    }
208}
209
210const savePermissionToDB = (permission) => saveValueToDB(PERMISSION_KEY, permission);
211const getPermissionFromDB = () => getValueFromDB(PERMISSION_KEY);
212const saveDeviceIdToDB = (deviceId) => saveValueToDB(DEVICE_ID_DB_KEY, deviceId);
213const getDeviceIdFromDB = () => getValueFromDB(DEVICE_ID_DB_KEY);
214
215/**
216 * Strips HTML tags and decodes common HTML entities from a string.
217 * Used to sanitize notification title and body before display.
218 *
219 * @param   {string} input - The raw string potentially containing HTML
220 *
221 * @returns {string}       The sanitized plain-text string
222 */
223function stripTags(input) {
224    if (!input) return '';
225    // Remove HTML tags and decode common HTML entities for security
226    return input
227        .replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, '')
228        .replace(/&lt;/g, '<')
229        .replace(/&gt;/g, '>')
230        .replace(/&amp;/g, '&')
231        .replace(/&quot;/g, '"')
232        .replace(/&#x27;/g, "'")
233        .trim();
234}
235
236/**
237 * Conditional logger — only emits output when the Twig `debug` flag is truthy.
238 * Delegates to `console[level]` so callers can use 'log', 'warn', or 'error'.
239 *
240 * @param {string} message          - The message to log
241 * @param {object} [context={}]     - Additional context object to log alongside the message
242 * @param {string} [level='log']    - The console method to use ('log', 'warn', 'error')
243 */
244function log(message, context = {}, level = 'log') {
245    }
246
247/**
248 * Removes keys whose value is falsy or the string 'null' from an object in place.
249 * Used to strip unset optional notification fields before passing them to showNotification.
250 *
251 * @param   {object} obj - The object to mutate
252 *
253 * @returns {object}     The same object with empty values removed
254 */
255function removeEmptyValues(obj) {
256    const keysToDelete = Object.keys(obj).filter(propName => !obj[propName] || obj[propName] === 'null');
257    keysToDelete.forEach(key => delete obj[key]);
258    return obj;
259}
260
261/**
262 * Wrapper around `self.registration.showNotification` that adds structured logging
263 * and re-throws errors so callers can apply their own fallback strategy.
264 *
265 * @param   {string}        title          - The notification title
266 * @param   {NotificationOptions} options  - Standard Web Notification options object
267 * @param   {object}        [context={}]   - Additional context forwarded to the logger
268 *
269 * @returns {Promise<void>}
270 */
271async function showNotificationSafely(title, options, context = {}) {
272    try {
273        log('Attempting to show notification', { title, options, context });
274
275        const notificationPromise = self.registration.showNotification(title, options);
276
277        const result = await notificationPromise;
278
279        log('Notification successfully shown', { title, tag: options?.tag, context });
280
281        return result;
282    } catch (error) {
283        log('Failed to show notification', { title, options, error, context }, 'error');
284
285        // Re-throw so caller knows it failed
286        throw error;
287    }
288}
289
290/**
291 * Reports a push notification open event to the push service.
292 * Fires-and-forgets a GET with the push ID and device ID so the backend can
293 * track open rates. Failures are logged but never rethrown so a network error
294 * never blocks the navigation.
295 *
296 * @param   {string|null}   pushId   - The unique push identifier (payload.data.push_id)
297 * @param   {string|null}   deviceId - The persistent browser device ID
298 *
299 * @returns {Promise<void>} - Resolves when the push open report is sent (or fails), but does not block on the result
300 */
301async function reportPushOpen(pushId, deviceId) {
302    try {
303        const url = `${PUSH_HOST}/service/pushopen/${encodeURIComponent(pushId)}${deviceId ? `/${encodeURIComponent(deviceId)}` : ''}`;
304        const response = await fetch(url, {
305            method: 'GET',
306            mode: 'no-cors',
307        });
308
309        log('Push open reported successfully (no-cors, response opaque).', { response, pushId, deviceId });
310    } catch (error) {
311        log('Failed to report push open (no-cors, response opaque).', { error, pushId, deviceId }, 'warn');
312    }
313}
314
315/**
316 * Handles a notification click by focusing an existing window already at the target URL
317 * or opening a new one. Uses origin + pathname comparison to tolerate trailing slashes,
318 * query strings, and hash fragments.
319 *
320 * @param   {string}        url - The URL to open or focus
321 *
322 * @returns {Promise<void>}
323 */
324async function handleNotificationClick(url) {
325    try {
326        const windowClients = await clients.matchAll({ type: 'window' });
327
328        // If a Window tab matching the targeted URL already exists, focus that.
329        // Use origin + pathname comparison instead of strict URL equality to handle
330        // cases where the tab URL differs only by trailing slash, query string or hash.
331        let targetUrl;
332        try {
333            targetUrl = new URL(url, self.registration.scope);
334        } catch {
335            targetUrl = null;
336        }
337
338        const existingWindow = windowClients.find(windowClient => {
339            if (!targetUrl) return windowClient.url === url;
340            try {
341                const clientUrl = new URL(windowClient.url);
342                return clientUrl.origin === targetUrl.origin && clientUrl.pathname === targetUrl.pathname;
343            } catch {
344                return false;
345            }
346        });
347
348        if (existingWindow) {
349            log('Focusing existing window for notification URL.', { url });
350            return existingWindow.focus();
351        }
352
353        // Otherwise, open a new tab to the applicable URL and focus it
354        log('Opening new window for notification URL.', { url });
355        const newWindow = await clients.openWindow(url);
356        if (newWindow) {
357            log('New window opened and focused.', { url });
358            return newWindow.focus();
359        } else {
360            // clients.openWindow() returns null on iOS Safari and Chrome Android
361            // even when the tab was successfully opened. This is expected behaviour —
362            // the browser opened the window but didn't return a client reference.
363            log('Window opened (no client reference returned — expected on mobile browsers).', { url });
364        }
365    } catch (error) {
366        log('Error handling notification click', { error, url }, 'error');
367    }
368}
369
370/**
371 * Resolves the website-level notification permission from the two available runtime sources.
372 *
373 * Prefers the LIVE `cachedNotificationPermission` global (set via postMessage from the
374 * main thread — most up-to-date) and falls back to IndexedDB when the postMessage
375 * hasn't arrived yet (`cachedNotificationPermission === null`, e.g. first push after SW
376 * restart before any tab sends an update).
377 *
378 * Reading `cachedNotificationPermission` live (not a captured snapshot) avoids a race
379 * where a postMessage arrives during an earlier `await` and a boolean snapshot would
380 * already be stale by the time this function is invoked.
381 *
382 * Note: this function only resolves the *website* permission. The caller is responsible
383 * for also AND-ing the result with the browser-level `Notification.permission`.
384 *
385 * A null persisted value is treated as UNKNOWN (never seeded or evicted), not denied,
386 * so an evicted IndexedDB after long inactivity no longer suppresses legitimate pushes.
387 *
388 * @param   {string|null} persistedPermission - IndexedDB permission, or null when unknown/evicted
389 *
390 * @returns {boolean}                          True if the website permission is considered granted
391 */
392function resolveWebsitePermission(persistedPermission) {
393    if (cachedNotificationPermission !== null) { return cachedNotificationPermission === NOTIFICATIONS_GRANTED; }
394
395    if (persistedPermission === null || persistedPermission === undefined) { return true; }
396
397    return persistedPermission === NOTIFICATIONS_GRANTED;
398}
399
400/**
401 * Syncs both the in-memory cache and IndexedDB to GRANTED when both permissions are confirmed.
402 *
403 * @param {boolean} shouldSync           - Whether syncing is necessary (both granted but state was stale)
404 * @param {boolean} previousCachedState  - Value of isCachedPermissionGranted before this call (for logging)
405 * @param {boolean} previousPersistedState - Value of dbPermission before this call (for logging)
406 * @returns {Promise<void>}
407 */
408async function syncPermissionStates(shouldSync, previousCachedState, previousPersistedState) {
409    if (!shouldSync) { return; }
410    cachedNotificationPermission = NOTIFICATIONS_GRANTED;
411    try {
412        await savePermissionToDB(NOTIFICATIONS_GRANTED);
413        log('Both permissions granted - synced cached and persisted states.', { previousCachedState, previousPersistedState });
414    } catch (error) {
415        log('Failed to sync persisted permission state', { error }, 'warn');
416    }
417}
418
419// Initialize Firebase
420let messaging;
421let isFirebaseReady = false;
422
423try {
424    const firebaseApp = firebase.initializeApp({
425    "apiKey": "AIzaSyCPqOjO6lCw33tbMcV3mKEIEd66L98TXZI",
426    "appId": "1:951535788597:web:e202f58aaf88833378c77c",
427    "authDomain": "figaro-gala.firebaseapp.com",
428    "databaseURL": null,
429    "measurementId": "G-Y2L88FMH61",
430    "messagingSenderId": 951535788597,
431    "projectId": "figaro-gala",
432    "storageBucket": "figaro-gala.appspot.com",
433    "vapidKey": "BGYKS1cusC2RcQabjpOG0CZXw-QtDyZ5GjJ_vIVyes6ouFsuvAxJ_77b7PN-Z1B6XcmAzepMpIS60uDr82CZlOE"
434});
435
436    if (!firebaseApp) {
437        throw new Error('Firebase app initialization returned null');
438    }
439
440    messaging = firebase.messaging();
441
442    if (!messaging) {
443        throw new Error('Firebase messaging initialization returned null');
444    }
445
446    isFirebaseReady = true;
447    log('Firebase initialized successfully', {});
448} catch (error) {
449    log('Firebase initialization failed.', { error }, 'error');
450}
451
452// Listen for permission updates from the main thread
453self.addEventListener('message', (event) => {
454    if (event.data && event.data.type === 'NOTIFICATION_PERMISSION_UPDATE') {
455        cachedNotificationPermission = event.data.permission;
456
457        event.waitUntil(
458            savePermissionToDB(cachedNotificationPermission)
459                .then(() => {
460                    log('Received permission update from main thread.', {
461                        permission: cachedNotificationPermission,
462                        persistedToIndexedDB: true
463                    });
464                })
465                .catch((error) => {
466                    // In-memory state is still authoritative for the current session
467                    log('Failed to persist permission to IndexedDB', { error, permission: cachedNotificationPermission }, 'error');
468                })
469        );
470    }
471
472    if (event.data && event.data.type === 'DEVICE_ID_UPDATE') {
473        const { deviceId } = event.data;
474        event.waitUntil(
475            saveDeviceIdToDB(deviceId)
476                .then(() => {
477                    log('Received device ID update from main thread.', { deviceId, persistedToIndexedDB: true });
478                })
479                .catch((error) => {
480                    // In-memory state is still authoritative for the current session
481                    log('Failed to persist device ID to IndexedDB', { error, deviceId }, 'error');
482                })
483        );
484    }
485});
486
487// Check if Firebase Messaging is supported
488if (isFirebaseReady && messaging) {
489    // Handle background messages using Firebase's onBackgroundMessage
490    messaging.onBackgroundMessage(async (payload) => {
491        // Gate display on the browser permission only (read directly by the SW). The former
492        // websitePermission layer suppressed real pushes when a subscribed user's opt-in was evicted.
493        // Notification is unavailable in this scope only in non-compliant environments — treat as not granted.
494        const browserPermission = self.Notification?.permission === NOTIFICATIONS_GRANTED;
495
496        // Persisted/cached website state kept for logging and self-healing only; it no longer gates display.
497        const persistedPermission = await getPermissionFromDB();
498        const websitePermission = resolveWebsitePermission(persistedPermission);
499
500        /*
501         * Only self-heal towards GRANTED when the persisted state is truly unknown
502         * (never seeded, or evicted — persistedPermission === null/undefined). An explicit
503         * 'denied'/'default' reflects a real opt-out and must never be overwritten just
504         * because the browser-level permission still shows granted (e.g. during the narrow
505         * crash-recovery window where a stale subscription outlives an in-site unsubscribe).
506         */
507        const isPersistedPermissionUnknown =
508            persistedPermission === null || persistedPermission === undefined;
509        await syncPermissionStates(browserPermission && isPersistedPermissionUnknown, cachedNotificationPermission === NOTIFICATIONS_GRANTED, persistedPermission === NOTIFICATIONS_GRANTED);
510        const hasPermission = browserPermission;
511
512        if (!hasPermission) {
513            // hasPermission === browserPermission here, so browserPermission is always false —
514            // the only possible reason for this branch is the browser-level permission itself.
515            log('Notification permission denied. Ignoring background message.', {
516                browserPermission,
517                browserState: self.Notification ? self.Notification.permission : 'unavailable',
518                cachedPermissionState: cachedNotificationPermission,
519                persistedPermissionState: persistedPermission,
520                websitePermission,
521                reason: 'browser permission not granted'
522            }, 'warn');
523
524            // CHROME REQUIREMENT: Must show a notification when receiving push message
525            // Per Chrome docs: "You always have to show a notification when you receive a push message"
526            // If we don't return a promise that resolves to a shown notification,
527            // Chrome will display: "This site has been updated in the background"
528            //
529            // Solution: Show minimal silent notification that auto-dismisses
530            return showNotificationSafely(`Le site ${CURRENT_SITE} a été mis à jour en arrière-plan`, {
531                silent: true,
532                tag: 'background-sync',
533                requireInteraction: false
534            }, { reason: 'permission-denied-silent' });
535        }
536
537        log('Notification permission granted. Processing background message.', {
538            browserPermission,
539            cachedPermissionState: cachedNotificationPermission,
540            persistedPermissionState: persistedPermission,
541            websitePermission,
542            permissionSource: cachedNotificationPermission !== null ? 'cached (in-memory)' : 'persisted (IndexedDB — cache not seeded yet)'
543        });
544
545        log('Received background message.', { payload });
546
547        // Extract data from payload - check both data and notification fields
548        try {
549            // Firebase Payload Structure
550
551            /**
552             * Firebase Cloud Messaging can have a data object that contain custom data fields
553             * `payload.data` - Custom fields you control
554             *
555             * @var {Object} payload                  - The notification payload
556             * @var {Object} payload.data             - The notification payload data
557             * @var {string} payload.data.title       - The notification title
558             * @var {string} payload.data.body        - The notification body
559             * @var {string} payload.data.url         - The notification destination URL
560             * @var {string} payload.data.image       - The notification image
561             * @var {string} payload.data.topic       - The notification topic
562             * @var {string} payload.data.type        - The notification type
563             * @var {string} payload.data.environment - The notification environment
564             * @var {string} payload.data.overline    - The notification overline
565             * @var {string} payload.data.push_id     - Unique push identifier for deduplication (in-memory 
565Set, TTL 60s)
566             */
567            const data = payload.data || {};
568
569            /**
570             * Firebase Cloud Messaging expects the payload to contain a notification object
571             * `payload.notification` - Firebase's built-in notification fields
572             *
573             * @var {Object} payload                  - The notification payload
574             * @var {Object} payload.notification.title - The notification title
575             * @var {string} payload.notification.body  - The notification body
576             * @var {string} payload.notification.icon  - The notification icon
577             *
578             */
579            const notification = payload.notification || {};
580
581            if (data.environment && data.environment !== SW_ENV) {
582                log('Environments are not aligned. Discarding message.', { expected: SW_ENV, received: data.environment }, 'error');
583
584                // Wrong environment = configuration error, discard the notification content.
585                // CHROME REQUIREMENT: A bare `return` triggers the default "This site has been
586                // updated in the background" notification. Show a silent one instead.
587                return showNotificationSafely(`Le site ${CURRENT_SITE} a été mis à jour en arrière-plan`, {
588                    silent: true,
589                    tag: 'environment-mismatch',
590                    requireInteraction: false
591                }, { reason: 'environment-mismatch', expected: SW_ENV, received: data.environment });
592            }
593
594            // Deduplicate by push_id: if the backend retried a failed FCM delivery we would
595            // otherwise show the same notification twice. The Set is in-memory (survives SW
596            // lifetime, not page reloads) with a 60s TTL — enough to cover any retry window.
597            const { push_id: pushId } = data;
598            if (pushId) {
599                if (processedPushIds.has(pushId)) {
600                    log('Duplicate push_id detected, skipping notification.', { pushId }, 'warn');
601                    return showNotificationSafely(`Le site ${CURRENT_SITE} a été mis à jour en arrière-plan`, {
602                        silent: true,
603                        tag: 'background-sync',
604                        requireInteraction: false
605                    }, { reason: 'duplicate-push-id', pushId });
606                }
607                processedPushIds.add(pushId);
608                setTimeout(() => processedPushIds.delete(pushId), PUSH_ID_TTL_MS);
609            }
610
611            // LEGACY PAYLOAD: When payload.notification is present, the browser shows the notification
612            // automatically via the FCM compat SDK — this cannot be prevented.
613            // onBackgroundMessage is still called, but showing a second notification here would
614            // result in a duplicate. Return early; Chrome's "must show notification" requirement
615            // is already satisfied by the automatic one.
616            const hasNotificationField = Object.keys(notification).length > 0;
617
618            log('Processing notification payload.', {
619                hasDataField: Object.keys(data).length > 0,
620                hasNotificationField,
621                notificationFields: Object.keys(notification),
622                dataFields: Object.keys(data)
623            });
624
625            if (hasNotificationField) {
626                log('Legacy payload: notification shown automatically by browser. Skipping manual display.', {
627                    notificationFields: Object.keys(notification)
628                });
629                return;
630            }
631
632            // Only data-only payloads reach this point (legacy notification payloads returned early above).
633            // Use custom data fields first, fall back to notification fields as a safety net.
634            const title = data.overline || data.title || notification.title || 'Notification';
635            const body = data.overline ? (data.title || notification.body) : (data.body || notification.body || null);
636
637            const notificationTitle = stripTags(title);
638            const notificationOptions = removeEmptyValues({
639                // Use push_id as tag when available: it uniquely identifies this exact push,
640                // so distinct articles on the same topic each get their own notification slot,
641                // and the same push can never stack if somehow delivered twice.
642                // Falls back to url -> topic for older payloads without push_id.
643                tag: pushId || data.url || data.topic || 'push-notification',
644                badge: data.badge || '/notifications-badge.png',
645                body: body ? stripTags(body) : null,
646                data: JSON.stringify(data),
647                icon: data.image || data.icon || notification.icon || '/notifications-icon.png',
648                image: data.image || notification.image || null,
649            });
650
651            log('Showing notification.', { notificationTitle, notificationOptions });
652            return showNotificationSafely(notificationTitle, notificationOptions, { source: 'background-message' });
653        } catch (error) {
654            log('Error processing background message', { error, payload }, 'error');
655
656            // CRITICAL: Show a silent notification to prevent Chrome's default notification
657            // Even if processing fails, we must show SOMETHING or Chrome shows "This site has been updated in the background"
658            return showNotificationSafely(`Le site ${CURRENT_SITE} a été mis à jour en arrière-plan`, {
659                silent: true,
660                tag: 'error-fallback',
661                requireInteraction: false
662            }, { reason: 'error-fallback', error: error.message }).catch((fallbackError) => {
663                log('Even fallback notification failed!', { fallbackError, originalError: error }, 'error');
664                // Last resort: show minimal notification
665                return self.registration.showNotification(`Le site ${CURRENT_SITE} a été mis à jour en arrière-plan`, {
666                    silent: true
667                });
668            });
669        }
670    });
671}

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.