1/* eslint-disable no-restricted-globals */ 2/* eslint-disable no-undef */ 3// ================================================================ 4// Firebase Cloud Messaging Service Worker - Advanced Version 5// ================================================================ 6// Features: 7// 1. Push notification handling with retry logic 8// 2. Notification grouping and badge management 9// 3. Notification actions (Quick actions from notification) 10// 4. Analytics and tracking 11// 5. Rate limiting to prevent spam 12// 6. IndexedDB persistence for reliability 13// 7. Error handling and logging system 14// ================================================================ 15 16importScripts("https://www.gstatic.com/firebasejs/11.5.0/firebase-app-compat.js"); 17importScripts("https://www.gstatic.com/firebasejs/11.5.0/firebase-messaging-compat.js"); 18 19// ================================================================ 20// Configuration 21// ================================================================ 22 23const firebaseConfig = { 24 apiKey: "AIzaSyCRoX9G8klhiQZJ68YABOJzuMyjJf5ncSI", 25 authDomain: "sarv-workspace365.firebaseapp.com", 26 projectId: "sarv-workspace365", 27 storageBucket: "sarv-workspace365.firebasestorage.app", 28 messagingSenderId: "788813090525", 29 appId: "1:788813090525:web:dcc718b7c57a5205b49534", 30 measurementId: "G-LMHSC73FZ9" 31}; 32 33// Advanced configuration options 34const CONFIG = { 35 // Notification settings 36 MAX_NOTIFICATIONS_PER_MINUTE: 10, 37 MAX_RETRY_ATTEMPTS: 3, 38 RETRY_DELAY: 2000, // 2 seconds 39 40 // Storage keys 41 DB_NAME: "console_sw_db", 42 DB_VERSION: 3, 43 STORE_NOTIFICATIONS: "notifications", 44 STORE_ANALYTICS: "analytics", 45 STORE_BADGE: "badge", 46 47 // Storage caps â oldest entries are dropped when exceeded 48 MAX_ANALYTICS_ENTRIES: 200, 49 MAX_NOTIFICATION_ENTRIES: 100, 50 51 // Debug mode: auto-enabled on localhost, off in production. 52 // Can also be toggled at runtime via ENABLE_DEBUG / DISABLE_DEBUG messages. 53 DEBUG: self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1", 54}; 55 56console.log("[MAIL-SW-BOOT] firebase-messaging-sw.js loaded", { 57 href: self.location.href, 58 hostname: self.location.hostname, 59}); 60 61// ================================================================ 62// Offline Cache Configuration 63// ================================================================ 64 65// Offline app-shell caching is temporarily disabled because this worker is 66// registered at the app root and was serving stale assets on normal reloads. 67// Keep this code commented for now so it can be restored later if needed. 68// 69// const CACHE_VERSION = "v1"; 70// const APP_SHELL_CACHE = `app-shell-${CACHE_VERSION}`; 71// const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`; 72// 73// // Core assets to pre-cache during install for offline support 74// const APP_SHELL_URLS = ["/", "/offline.html"]; 75 76// ================================================================ 77// Utility Functions 78// ================================================================ 79 80/** 81 * Advanced logging system with levels 82 */ 83const Logger = { 84 _log(level, ...args) { 85 if (!CONFIG.DEBUG && level === "debug") { 86 return; 87 } 88 const timestamp = new Date().toISOString(); 89 const prefix = `[SW ${level.toUpperCase()}] ${timestamp}:`; 90 console[level === "error" ? "error" : "log"](prefix, ...args); 91 }, 92 93 debug(...args) { 94 this._log("debug", ...args); 95 }, 96 97 info(...args) { 98 this._log("info", ...args); 99 }, 100 101 warn(...args) { 102 this._log("warn", ...args); 103 }, 104 105 error(...args) { 106 this._log("error", ...args); 107 } 108}; 109 110/** 111 * IndexedDB wrapper for persistent storage 112 */ 113class StorageManager { 114 constructor() { 115 this.db = null; 116 } 117 118 async init() { 119 if (this.db) { 120 return this.db; 121 } 122 123 return new Promise((resolve, reject) => { 124 const request = indexedDB.open(CONFIG.DB_NAME, CONFIG.DB_VERSION); 125 126 request.onerror = () => { 127 Logger.error("IndexedDB open failed:", request.error); 128 reject(request.error); 129 }; 130 131 request.onsuccess = () => { 132 this.db = request.result; 133 Logger.debug("IndexedDB opened successfully"); 134 resolve(this.db); 135 }; 136
137 request.onupgradeneeded = (event) => { 138 const db = event.target.result; 139 140 if (!db.objectStoreNames.contains(CONFIG.STORE_NOTIFICATIONS)) { 141 const notificationStore = db.createObjectStore(CONFIG.STORE_NOTIFICATIONS, { keyPath: "id", autoIncrement: true }); 142 notificationStore.createIndex("timestamp", "timestamp", { unique: false }); 143 notificationStore.createIndex("product", "product", { unique: false }); 144 Logger.debug("Created notification store"); 145 } 146 147 if (!db.objectStoreNames.contains(CONFIG.STORE_ANALYTICS)) { 148 const analyticsStore = db.createObjectStore(CONFIG.STORE_ANALYTICS, { keyPath: "id", autoIncrement: true }); 149 analyticsStore.createIndex("type", "type", { unique: false }); 150 analyticsStore.createIndex("timestamp", "timestamp", { unique: false }); 151 Logger.debug("Created analytics store"); 152 } 153 154 if (!db.objectStoreNames.contains(CONFIG.STORE_BADGE)) { 155 db.createObjectStore(CONFIG.STORE_BADGE, { keyPath: "id" }); 156 Logger.debug("Created badge store"); 157 } 158 }; 159 }); 160 } 161 162 async set(storeName, data) { 163 try { 164 await this.init(); 165 return new Promise((resolve, reject) => { 166 const transaction = this.db.transaction([storeName], "readwrite"); 167 const store = transaction.objectStore(storeName); 168 const request = store.put(data); 169 170 request.onsuccess = () => resolve(request.result); 171 request.onerror = () => reject(request.error); 172 }); 173 } catch (error) { 174 Logger.error("Storage set error:", error); 175 throw error; 176 } 177 } 178 179 async get(storeName, key) { 180 try { 181 await this.init(); 182 return new Promise((resolve, reject) => { 183 const transaction = this.db.transaction([storeName], "readonly"); 184 const store = transaction.objectStore(storeName); 185 const request = store.get(key); 186 187 request.onsuccess = () => resolve(request.result); 188 request.onerror = () => reject(request.error); 189 }); 190 } catch (error) { 191 Logger.error("Storage get error:", error); 192 return null; 193 } 194 } 195 196 async getAll(storeName) { 197 try { 198 await this.init(); 199 return new Promise((resolve, reject) => { 200 const transaction = this.db.transaction([storeName], "readonly"); 201 const store = transaction.objectStore(storeName); 202 const request = store.getAll(); 203 204 request.onsuccess = () => resolve(request.result); 205 request.onerror = () => reject(request.error); 206 }); 207 } catch (error) { 208 Logger.error("Storage getAll error:", error); 209 return []; 210 } 211 } 212 213 async delete(storeName, key) { 214 try { 215 await this.init(); 216 return new Promise((resolve, reject) => { 217 const transaction = this.db.transaction([storeName], "readwrite"); 218 const store = transaction.objectStore(storeName); 219 const request = store.delete(key); 220 221 request.onsuccess = () => resolve(); 222 request.onerror = () => reject(request.error); 223 }); 224 } catch (error) { 225 Logger.error("Storage delete error:", error); 226 } 227 } 228 229 async clear(storeName) { 230 try { 231 await this.init(); 232 return new Promise((resolve, reject) => { 233 const transaction = this.db.transaction([storeName], "readwrite"); 234 const store = transaction.objectStore(storeName); 235 const request = store.clear(); 236 237 request.onsuccess = () => resolve(); 238 request.onerror = () => reject(request.error); 239 }); 240 } catch (error) { 241 Logger.error("Storage clear error:", error); 242 } 243 } 244 245 /** 246 * Trim a store to maxEntries by deleting the oldest records (by timestamp). 247 * Called after every write to prevent unbounded IndexedDB growth. 248 */ 249 async trim(storeName, maxEntries) { 250 try { 251 await this.init(); 252 return new Promise((resolve, reject) => { 253 const transaction = this.db.transaction([storeName], "readwrite"); 254 const store = transaction.objectStore(storeName); 255 const request = store.getAll(); 256 257 request.onsuccess = () => { 258 const records = request.result; 259 if (records.length <= maxEntries) { 260 resolve(); 261 return; 262 } 263 264 // Sort oldest-first by timestamp, delete the excess 265 records.sort((a, b) => a.timestamp - b.timestamp); 266 const toDelete = records.slice(0, records.length - maxEntries); 267 Logger.debug(`Trimming ${toDelete.length} old entries from "${storeName}"`); 268 269 let pending = toDelete.length; 270 toDelete.forEach((record) => { 271 const del = store.delete(record.id); 272 del.onsuccess = () => { if (--pending === 0) resolve(); }; 273 del.onerror = () => reject(del.error); 274 }); 275 }; 276 277 request.onerror = () => reject(request.error); 278 }); 279 } catch (error) { 280 Logger.error("Storage trim error:", error); 281 } 282 } 283} 284 285const storage = new StorageManager(); 286 287/** 288 * Rate limiter to prevent notification spam 289 */ 290class RateLimiter { 291 constructor() { 292 this.timestamps = []; 293 } 294 295 isAllowed() { 296 const now = Date.now(); 297 const oneMinuteAgo = now - 60000; 298 299 // Remove timestamps older than 1 minute 300 this.timestamps = this.timestamps.filter(t => t > oneMinuteAgo); 301 302 if (this.timestamps.length >= CONFIG.MAX_NOTIFICATIONS_PER_MINUTE) {
303 Logger.warn("Rate limit exceeded, notification blocked"); 304 return false; 305 } 306 307 this.timestamps.push(now); 308 return true; 309 } 310 311 reset() { 312 this.timestamps = []; 313 } 314} 315 316const rateLimiter = new RateLimiter(); 317 318/** 319 * Sound Manager 320 * - Per-product cooldown: prevents repeated sounds for the same notification type 321 * - Burst detection: prevents all queued sounds from playing at once (e.g. after Mac sleep/wake) 322 */ 323const SoundManager = { 324 lastSoundByProduct: new Map(), // product -> last played timestamp 325 recentNotificationTimes: [], // timestamps of recent notifications (for burst detection) 326 327 PRODUCT_COOLDOWN_MS: 8000, // same product type: only one sound per 8 seconds 328 BURST_WINDOW_MS: 3000, // burst detection window 329 BURST_THRESHOLD: 2, // if >= 2 notifications arrive within BURST_WINDOW_MS, it's a burst 330 331 canPlaySound(product) { 332 const now = Date.now(); 333 334 // Clean entries outside burst window 335 this.recentNotificationTimes = this.recentNotificationTimes.filter( 336 (t) => now - t < this.BURST_WINDOW_MS 337 ); 338 339 // Check burst â if already saw >= BURST_THRESHOLD notifications recently, skip sound 340 // (handles sleep/wake scenario where all queued notifications arrive at once) 341 if (this.recentNotificationTimes.length >= this.BURST_THRESHOLD) { 342 Logger.warn( 343 `ð [Sound] Burst detected (${this.recentNotificationTimes.length + 1} notifications in ${this.BURST_WINDOW_MS}ms) â skipping sound` 344 ); 345 this.recentNotificationTimes.push(now); 346 return false; 347 } 348 349 // Track this notification for burst detection 350 this.recentNotificationTimes.push(now); 351 352 // Per-product cooldown check (Issue 1: back-to-back same type) 353 const lastForProduct = this.lastSoundByProduct.get(product) || 0; 354 if (now - lastForProduct < this.PRODUCT_COOLDOWN_MS) { 355 Logger.info( 356 `ð [Sound] Cooldown active for "${product}" (${Math.round((now - lastForProduct) / 1000)}s ago) â skipping sound` 357 ); 358 return false; 359 } 360 361 return true; 362 }, 363 364 markPlayed(product) { 365 this.lastSoundByProduct.set(product, Date.now()); 366 }, 367}; 368 369/** 370 * Analytics tracker 371 */ 372class AnalyticsTracker { 373 async track(eventType, data = {}) { 374 try { 375 const event = { 376 type: eventType, 377 timestamp: Date.now(), 378 data: data, 379 }; 380 381 await storage.set(CONFIG.STORE_ANALYTICS, event); 382 await storage.trim(CONFIG.STORE_ANALYTICS, CONFIG.MAX_ANALYTICS_ENTRIES); 383 Logger.debug("Analytics tracked:", eventType, data); 384 385 // Send to server (could be implemented) 386 this._sendToServer(event); 387 } catch (error) { 388 Logger.error("Analytics tracking failed:", error); 389 } 390 } 391 392 _sendToServer(event) { 393 // Send analytics to server when online 394 // This could be implemented with fetch() or background sync 395 Logger.debug("Analytics event ready for server:", event); 396 } 397} 398 399const analytics = new AnalyticsTracker(); 400 401// ================================================================ 402// Firebase Initialization 403// ================================================================ 404 405let messaging = null; 406 407try { 408 firebase.initializeApp(firebaseConfig); 409 messaging = firebase.messaging(); 410 Logger.info("Firebase initialized successfully"); 411} catch (error) { 412 Logger.error("Firebase initialization failed:", error); 413} 414 415// ================================================================ 416// Notification Badge Management 417// ================================================================ 418 419class BadgeManager { 420 constructor() { 421 this.count = 0; 422 } 423 424 /** 425 * Restore count from IndexedDB on SW restart so the badge survives termination. 426 */ 427 async init() { 428 try { 429 const stored = await storage.get(CONFIG.STORE_BADGE, "count"); 430 if (stored && typeof stored.value === "number") { 431 this.count = stored.value; 432 await this.update(); 433 Logger.debug("Badge count restored from IndexedDB:", this.count); 434 } 435 } catch (error) { 436 Logger.error("Failed to restore badge count:", error); 437 } 438 } 439
440 async increment() { 441 this.count++; 442 await this.update(); 443 } 444 445 async decrement() { 446 if (this.count > 0) { 447 this.count--; 448 } 449 await this.update(); 450 } 451 452 async reset() { 453 this.count = 0; 454 await this.update(); 455 } 456 457 async update() { 458 try { 459 // Persist count so it survives SW termination 460 await storage.set(CONFIG.STORE_BADGE, { id: "count", value: this.count }); 461 462 if ("setAppBadge" in navigator) { 463 if (this.count > 0) { 464 await navigator.setAppBadge(this.count); 465 } else { 466 await navigator.clearAppBadge(); 467 } 468 Logger.debug("Badge updated:", this.count); 469 } 470 } catch (error) { 471 Logger.error("Badge update failed:", error); 472 } 473 } 474} 475 476const badgeManager = new BadgeManager(); 477 478// ================================================================ 479// Notification Processing 480// ================================================================ 481 482/** 483 * Display notification with retry logic 484 */ 485async function displayNotificationWithRetry(title, options, retryCount = 0) { 486 try { 487 console.log("[MAIL-SW-1] showNotification:start", { 488 title, 489 body: options?.body, 490 icon: options?.icon, 491 product: options?.data?.product, 492 url: options?.data?.url, 493 tag: options?.tag, 494 retryCount, 495 }); 496 await self.registration.showNotification(title, options); 497 console.log("[MAIL-SW-2] showNotification:success", { 498 title, 499 body: options?.body, 500 icon: options?.icon, 501 product: options?.data?.product, 502 url: options?.data?.url, 503 tag: options?.tag, 504 }); 505 Logger.info("Notification displayed:", title); 506 await analytics.track("notification_displayed", { title, product: options.data?.product }); 507 await badgeManager.increment(); 508 return true; 509 } catch (error) { 510 console.log("[MAIL-SW-3] showNotification:error", { 511 title, 512 body: options?.body, 513 icon: options?.icon, 514 product: options?.data?.product, 515 url: options?.data?.url, 516 tag: options?.tag, 517 retryCount, 518 error: error?.message || error, 519 stack: error?.stack || null, 520 }); 521 Logger.error(`Notification display failed (attempt ${retryCount + 1}):`, error); 522 523 if (retryCount < CONFIG.MAX_RETRY_ATTEMPTS) { 524 await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY)); 525 return displayNotificationWithRetry(title, options, retryCount + 1); 526 } 527 528 await analytics.track("notification_failed", { title, error: error.message }); 529 return false; 530 } 531} 532 533/** 534 * Process Meet product notifications with advanced features 535 */ 536function processMeetNotification(payload) { 537 const notificationOptions = { 538 body: payload.data?.body || "You have a new meeting", 539 icon: payload.data?.icon || "/icons/default-icon.png", 540 badge: "/icons/badge-icon.png", 541 tag: `meet-${payload.messageId}`, 542 renotify: true, 543 requireInteraction: true, // Meeting notifications should persist 544 vibrate: [200, 100, 200], // Vibration pattern 545 data: { 546 url: payload.data?.redirectionPath || "/meet", 547 payload: payload, 548 playSound: true, 549 product: "meet", 550 timestamp: Date.now(), 551 }, 552 actions: [ 553 { 554 action: "join", 555 title: "Join Now", 556 icon: "/icons/join-icon.png" 557 }, 558 { 559 action: "dismiss", 560 title: "Dismiss", 561 icon: "/icons/dismiss-icon.png" 562 } 563 ] 564 }; 565 566 Logger.debug("Processing Meet notification:", payload.data?.title);
567 return displayNotificationWithRetry( 568 payload.data?.title || "New Meeting", 569 notificationOptions 570 ); 571} 572 573/** 574 * Process default product notifications 575 */ 576function processDefaultNotification(payload) { 577 const product = payload.data?.product || "default"; 578 const notificationBody = 579 product.toLowerCase() === "chat" 580 ? payload.data?.body || 581 payload.data?.message || 582 payload.data?.messgae || 583 payload.notification?.body || 584 "You have a new notification" 585 : payload.data?.body || "You have a new notification"; 586 587 console.log("[MAIL-SW-4] processDefaultNotification:start", { 588 product, 589 title: payload.data?.title, 590 body: notificationBody, 591 rawBody: payload.data?.body, 592 rawMessage: payload.data?.message, 593 rawMessgae: payload.data?.messgae, 594 rawNotificationBody: payload.notification?.body, 595 redirectionPath: payload.data?.redirectionPath, 596 notificationType: payload.data?.notificationType, 597 thridId: payload.data?.thridId, 598 }); 599 600 // Web pushes are data-only (the backend no longer sends a notification block â 601 // that made the FCM SDK render the push itself on top of this handler, so every 602 // notification appeared twice). Grouping/priority intent therefore arrives in 603 // `data` and has to be applied here instead of by the SDK. 604 // 605 // `notificationTag` is only sent for collapse products (chat/teams), where the 606 // newest message should replace the previous one for that conversation. Mail has 607 // no tag on purpose, so mail notifications accumulate rather than overwrite. 608 const backendTag = payload.data?.notificationTag; 609 const collapseTag = backendTag || `${product}-${payload.messageId}`; 610 611 const notificationOptions = { 612 body: notificationBody, 613 icon: 614 payload.data?.icon || 615 payload.notification?.icon || 616 "/icons/default-icon.png", 617 badge: "/icons/badge-icon.png", 618 tag: collapseTag, 619 // renotify only has meaning alongside a tag: it re-alerts on replace instead of 620 // swapping the existing notification out silently. 621 renotify: payload.data?.renotify === "true", 622 // Deliberately NOT taken from the payload. macOS Notification Center does not 623 // support requireInteraction: Chrome accepts the notification but delivers it with 624 // no banner, straight into Notification Center, so honouring the backend's 625 // high-priority flag here silently killed every visible chat/mail banner on 626 // Chrome/macOS. Firefox ignores the option, which is why only Chrome regressed. 627 // Calls that genuinely need persistence set it themselves in processMeetNotification. 628 requireInteraction: false, 629 vibrate: [100, 50, 100], 630 data: { 631 url: payload.data?.redirectionPath || "/", 632 payload: payload, 633 playSound: true, 634 product: product, 635 timestamp: Date.now(), 636 }, 637 actions: [ 638 { 639 action: "view", 640 title: "View", 641 icon: "/icons/view-icon.png" 642 }, 643 { 644 action: "dismiss", 645 title: "Dismiss", 646 icon: "/icons/dismiss-icon.png" 647 } 648 ] 649 }; 650 651 Logger.debug("Processing default notification:", payload.data?.title); 652 console.log("[MAIL-SW-5] processDefaultNotification:options", { 653 product, 654 title: payload.data?.title || "New Notification", 655 options: notificationOptions, 656 });
657 return displayNotificationWithRetry( 658 payload.data?.title || "New Notification", 659 notificationOptions 660 ); 661} 662 663/** 664 * Store notification for offline handling 665 */ 666async function storeNotification(payload) { 667 try { 668 const notificationRecord = { 669 payload: payload, 670 timestamp: Date.now(), 671 product: payload.data?.product || "default", 672 processed: false, 673 }; 674 675 await storage.set(CONFIG.STORE_NOTIFICATIONS, notificationRecord); 676 await storage.trim(CONFIG.STORE_NOTIFICATIONS, CONFIG.MAX_NOTIFICATION_ENTRIES); 677 Logger.debug("Notification stored for later processing"); 678 } catch (error) { 679 Logger.error("Failed to store notification:", error); 680 } 681} 682 683/** 684 * Notify clients to play sound 685 */ 686async function notifyClientsPlaySound(payload) { 687 try { 688 const product = payload?.data?.product || "default"; 689 Logger.info("ð [Sound] Attempting to notify clients for sound playback"); 690 Logger.info("ð [Sound] Payload product:", product); 691 692 // Guard: per-product cooldown + burst detection (sleep/wake) 693 if (!SoundManager.canPlaySound(product)) { 694 await analytics.track("sound_skipped", { product, timestamp: Date.now() }); 695 return; 696 } 697 698 const clientsList = await clients.matchAll({ type: "window", includeUncontrolled: true }); 699 // Only send to top-level windows, not embedded iframes 700 const topLevelClients = clientsList.filter((c) => !c.frameType || c.frameType === "top-level"); 701 702 if (topLevelClients.length === 0) { 703 Logger.warn("ð [Sound] â ï¸ No active clients found - sound will not play"); 704 await analytics.track("sound_no_clients", { 705 product, 706 timestamp: Date.now() 707 }); 708 return; 709 } 710 711 Logger.info(`ð [Sound] â Found ${topLevelClients.length} active client(s), sending sound message`); 712 713 // meet: send to ALL tabs so the call popup shows on whichever tab the user is on. 714 // chat/mail/other: send to ONE tab only â each tab plays sound independently, 715 // so sending to all would cause N sounds for N open tabs. 716 const isMeet = product === "meet"; 717 const soundTargets = isMeet ? topLevelClients : [topLevelClients[0]]; 718 719 soundTargets.forEach((client, index) => { 720 Logger.debug(`ð [Sound] Sending to client ${index + 1}/${soundTargets.length}, ID: ${client.id}`); 721 client.postMessage({ 722 type: "FCM_NOTIFICATION", 723 payload: payload, 724 playSound: true, 725 timestamp: Date.now(), 726 }); 727 }); 728 729 // Mark as played only after successfully sending to clients 730 SoundManager.markPlayed(product); 731 732 Logger.info("ð [Sound] â Sound notification message sent successfully to all clients"); 733 734 await analytics.track("sound_notification_sent", { 735 product, 736 clientCount: soundTargets.length, 737 timestamp: Date.now() 738 }); 739 } catch (error) { 740 Logger.error("ð [Sound] â Failed to notify clients for sound:", error); 741 await analytics.track("sound_notification_failed", { 742 product: payload?.data?.product, 743 error: error.message, 744 timestamp: Date.now() 745 }); 746 } 747} 748 749// ================================================================ 750// Service Worker Lifecycle Events 751// ================================================================ 752 753self.addEventListener("install", (event) => { 754 Logger.info("Service Worker installing"); 755 756 event.waitUntil( 757 Promise.all([ 758 self.skipWaiting(), 759 storage.init(), 760 // Pre-cache app shell for offline support 761 // caches.open(APP_SHELL_CACHE).then((cache) => { 762 // Logger.info("[Cache] Pre-caching app shell"); 763 // return cache.addAll(APP_SHELL_URLS); 764 // }).catch((err) => { 765 // Logger.warn("[Cache] App shell pre-cache failed (non-fatal):", err); 766 // }), 767 ]).then(() => { 768 Logger.info("Service Worker installed successfully"); 769 }) 770 ); 771}); 772 773self.addEventListener("activate", (event) => { 774 Logger.info("Service Worker activating"); 775 776 // const validCaches = [APP_SHELL_CACHE, DYNAMIC_CACHE]; 777 778 event.waitUntil( 779 Promise.all([ 780 self.clients.claim(),
781 storage.init(), 782 badgeManager.init(), 783 // Remove outdated caches 784 // caches.keys().then((cacheNames) => 785 // Promise.all( 786 // cacheNames 787 // .filter((name) => !validCaches.includes(name)) 788 // .map((name) => { 789 // Logger.info(`[Cache] Deleting old cache: ${name}`); 790 // return caches.delete(name); 791 // }) 792 // ) 793 // ), 794 ]).then(async () => { 795 Logger.info("Service Worker activated successfully"); 796 await analytics.track("sw_activated"); 797 }) 798 ); 799}); 800 801// ================================================================ 802// Fetch Handler (Offline Caching) 803// ================================================================ 804 805// Offline fetch interception is temporarily disabled because this worker was 806// caching app assets with a cache-first strategy, which caused stale bundles 807// to survive normal reloads. Keep the implementation commented for now. 808// 809// self.addEventListener("fetch", (event) => { 810// const { request } = event; 811// const url = new URL(request.url); 812// 813// // Only handle GET requests from the same origin 814// if (request.method !== "GET") return; 815// if (url.origin !== self.location.origin) return; 816// if (url.protocol === "chrome-extension:") return; 817// 818// // API calls â network first, fall back to cached response 819// if (url.pathname.startsWith("/api/")) { 820// event.respondWith(networkFirst(request)); 821// return; 822// } 823// 824// // PDFs â cache first (3.7 MB of guide/crf PDFs; rare changes, big download savings) 825// if (url.pathname.endsWith(".pdf")) { 826// event.respondWith(cacheFirst(request)); 827// return; 828// } 829// 830// // Static assets (JS, CSS, images, fonts) â cache first, fetch on miss 831// if ( 832// request.destination === "script" || 833// request.destination === "style" || 834// request.destination === "image" || 835// request.destination === "font" 836// ) { 837// event.respondWith(cacheFirst(request)); 838// return; 839// } 840// 841// // HTML navigation â network first, serve offline page when offline 842// if (request.mode === "navigate") { 843// event.respondWith(navigateFetch(request)); 844// return; 845// } 846// }); 847// 848// async function cacheFirst(request) { 849// const cached = await caches.match(request); 850// if (cached) return cached; 851// try { 852// const response = await fetch(request); 853// if (response.ok) { 854// const cache = await caches.open(DYNAMIC_CACHE); 855// cache.put(request, response.clone()); 856// } 857// return response; 858// } catch (err) { 859// Logger.warn("[Cache] cacheFirst fetch failed:", err); 860// return new Response("Offline", { status: 503, statusText: "Service Unavailable" }); 861// } 862// } 863// 864// async function networkFirst(request) { 865// try { 866// const response = await fetch(request); 867// if (response.ok) { 868// const cache = await caches.open(DYNAMIC_CACHE); 869// cache.put(request, response.clone()); 870// } 871// return response; 872// } catch (err) { 873// Logger.warn("[Cache] networkFirst falling back to cache:", err); 874// const cached = await caches.match(request); 875// if (cached) return cached; 876// return new Response( 877// JSON.stringify({ offline: true, error: "No network connection" }), 878// { status: 503, headers: { "Content-Type": "application/json" } } 879// ); 880// } 881// } 882// 883// async function navigateFetch(request) { 884// try { 885// return await fetch(request); 886// } catch (err) { 887// Logger.warn("[Cache] Navigation offline, serving fallback:", err); 888// return ( 889// (await caches.match(request)) || 890// (await caches.match("/")) || 891// (await caches.match("/offline.html")) || 892// new Response("<h1>You are offline</h1>", { headers: { "Content-Type": "text/html" } }) 893// ); 894// } 895// } 896 897// ================================================================ 898// Push Notification Handler 899// ================================================================ 900 901// Kept in exact sync with isCallEndedPayload() in 902// frontend/packages/console-spinner-embedded/src/hooks/useCallNotifications.jsx â
903// this runs in the service worker so it can't import that copy directly, but any 904// signal added/removed here must be mirrored there (and vice versa), otherwise one 905// context can decide "ended" while the other doesn't, leaving the popup stuck open 906// in whichever tab disagreed. 907function isCallEndedPayload(payload) { 908 const data = payload.data || {}; 909 const title = (data.title || "").toLowerCase(); 910 const body = (data.body || "").toLowerCase(); 911 const status = (data.callStatus || "").toLowerCase(); 912 const attendee = (data.attendeeStatus || "").toLowerCase(); 913 914 const result = ( 915 ["ended", "notanswered", "not_answered", "missed", "noanswer"].includes(status) || 916 ["accept", "accepted", "reject", "rejected"].includes(attendee) || 917 title === "call ended" || 918 title === "call response" || 919 title === "missed call" || 920 title === "call missed" || 921 body === "the call has ended" || 922 body === "user has responded to the call" 923 ); 924 925 console.warn("[SW-Push] isCallEndedPayload:", { title, body, status, attendee, result }); 926 return result; 927} 928 929if (messaging) { 930 messaging.onBackgroundMessage(async (payload) => { 931 console.log(payload,"payload at line 931 starting function beofre any chnges") 932 const product = (payload.data?.product || "default").toLowerCase(); 933 const title = payload.data?.title; 934 const body = payload.data?.body; 935 936 console.warn("[SW-Push] onBackgroundMessage received", { 937 product, 938 title, 939 body, 940 roomId: payload.data?.roomId, 941 callType: payload.data?.callType, 942 messageId: payload.messageId, 943 fullData: payload.data, 944 }); 945 946 // Skip OS notification and dismiss in-app popup for call ended / responded 947 if (isCallEndedPayload(payload)) { 948 console.warn("[SW-Push] Call ended/responded â closing OS notification + dismissing in-app popup", { title, body }); 949 950 // Close any existing OS call notification for this room (works even when user 951 // hasn't touched the notification â this is the only code path that can do it) 952 const endedRoomId = payload.data?.roomId; 953 try { 954 const existing = await self.registration.getNotifications(); 955 existing.forEach((notif) => { 956 if (notif.data?.product !== "meet") return; 957 const notifRoomId = notif.data?.payload?.data?.roomId; 958 if (!endedRoomId || !notifRoomId || String(notifRoomId) === String(endedRoomId)) { 959 notif.close(); 960 Logger.info("[SW-Push] Closed OS call notification for roomId:", endedRoomId); 961 } 962 }); 963 } catch (err) { 964 Logger.error("[SW-Push] Failed to close OS notification:", err); 965 } 966 967 // Also tell any open app tabs to dismiss their in-app popup 968 const clientsList = await clients.matchAll({ type: "window", includeUncontrolled: true }); 969 clientsList.forEach((client) => { 970 client.postMessage({ type: "FCM_NOTIFICATION", payload, playSound: false }); 971 }); 972 return; 973 } 974 975 Logger.info("ð© [Push] Background message received"); 976 Logger.info("ð© [Push] Payload:", JSON.stringify(payload, null, 2)); 977 Logger.info("ð© [Push] Product type:", product); 978 Logger.info("ð© [Push] Timestamp:", new Date().toISOString()); 979 console.log("[MAIL-SW-6] onBackgroundMessage:received", { 980 product, 981 title, 982 body, 983 message: payload.data?.message, 984 messgae: payload.data?.messgae, 985 notificationBody: payload.notification?.body, 986 notificationType: payload.data?.notificationType, 987 thridId: payload.data?.thridId, 988 }); 989 990 // Silent templates (e.g. chat TYPING) must not surface anything. Web payloads are 991 // data-only now, so there is no notification block for the SDK to omit â the intent 992 // arrives as data.silent and has to be honoured here. Checked BEFORE the rate 993 // limiter so silent pings never consume the visible-notification budget. 994 if (payload.data?.silent === "true") { 995 Logger.info("ð© [Push] Silent notification â no OS notification, no sound"); 996 return; 997 } 998 999 // Rate limiting check 1000 if (!rateLimiter.isAllowed()) {
1001 Logger.warn("ð© [Push] â ï¸ Notification blocked by rate limiter (too many notifications)"); 1002 await analytics.track("notification_rate_limited", { payload }); 1003 return; 1004 } 1005 1006 Logger.info("ð© [Push] â Rate limit check passed"); 1007 1008 // Store notification for analytics/history 1009 await storeNotification(payload); 1010 1011 let notificationPromise; 1012 1013 // Process based on product type 1014 Logger.info(`ð© [Push] Processing notification for product: ${product}`); 1015 switch (product) { 1016 case "meet": 1017 Logger.debug("ð© [Push] Using Meet notification processor"); 1018 notificationPromise = processMeetNotification(payload); 1019 break; 1020 default: 1021 Logger.debug("ð© [Push] Using default notification processor"); 1022 notificationPromise = processDefaultNotification(payload); 1023 break; 1024 } 1025 1026 // Wait for the OS notification to be shown before triggering sound, 1027 // so the user sees the notification and hears the sound at the same time. 1028 await notificationPromise; 1029 1030 // Notify clients to play sound (THIS IS CRITICAL FOR SOUND) 1031 Logger.info("ð© [Push] Now sending sound notification to clients..."); 1032 await notifyClientsPlaySound(payload); 1033 1034 Logger.info("ð© [Push] â Background message processing complete"); 1035 1036 // For meet calls: keep the SW alive for 30 seconds and auto-dismiss the OS 1037 // notification if the user hasn't interacted with it (accepted or declined). 1038 // This covers the case where the app is in the background or not open at all. 1039 if (product === "meet") { 1040 const meetTag = `meet-${payload.messageId}`; 1041 await new Promise((resolve) => { 1042 setTimeout(async () => { 1043 try { 1044 const notifs = await self.registration.getNotifications({ tag: meetTag }); 1045 notifs.forEach((n) => { 1046 n.close(); 1047 Logger.info("[SW] Auto-dismissed OS call notification after 30s, tag:", meetTag); 1048 }); 1049 } catch (err) { 1050 Logger.error("[SW] Auto-dismiss error:", err); 1051 } 1052 resolve(); 1053 }, 30_000); 1054 }); 1055 } 1056 }); 1057} else { 1058 Logger.error("ð© [Push] â Firebase messaging not initialized - push notifications will not work"); 1059} 1060 1061// ================================================================ 1062// Notification Click Handler (Advanced) 1063// ================================================================ 1064 1065self.addEventListener("notificationclick", (event) => { 1066 Logger.info("Notification clicked:", event.action); 1067 1068 event.notification.close(); 1069 1070 const notificationData = event.notification.data || {}; 1071 const action = event.action; 1072 1073 // Track click analytics 1074 analytics.track("notification_clicked", { 1075 action: action, 1076 product: notificationData.product, 1077 tag: event.notification.tag, 1078 }); 1079 1080 // Decrement badge 1081 badgeManager.decrement(); 1082 1083 // Handle notification actions 1084 if (action === "dismiss") { 1085 Logger.info("Notification dismissed by user"); 1086 return; 1087 } 1088console.log(notificationData,"notificationData at line 1080") 1089 // Determine target URL based on action. 1090 // `.url` is what THIS file's own processDefaultNotification()/processMeetNotification() 1091 // set when they build the notification (via onBackgroundMessage). But when the FCM 1092 // payload includes a `notification` block, Chrome auto-displays it NATIVELY and 1093 // bypasses onBackgroundMessage entirely â so event.notification.data is instead 1094 // whatever the raw payload's `notification.data` was, which uses `redirectionPath` 1095 // (and `click_action`), not `url`. Falling back to it here is the only frontend-side 1096 // fix possible for that case â the auto-display itself can't be intercepted from JS. 1097 let targetUrl = notificationData.url || notificationData.redirectionPath || "/"; 1098 console.log("[MAIL-SW-7] notificationclick:start", { 1099 action, 1100 product: notificationData.product, 1101 targetUrl, 1102 payload: notificationData.payload?.data || null, 1103 }); 1104 1105 if (action === "join" && notificationData.product === "meet") { 1106 event.waitUntil( 1107 clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => { 1108 const d = notificationData.payload?.data || {}; 1109 1110 // Resolve accountId from the payload or the redirectionPath URL 1111 const resolveAccountIdFromUrl = (url) => { 1112 try { 1113 const { pathname } = new URL(url, self.location.origin);
1114 const segments = pathname.split("/").filter(Boolean); 1115 const accountMarkerIndex = segments.indexOf("u"); 1116 return segments[accountMarkerIndex + 1] || "0"; 1117 } catch { 1118 return "0"; 1119 } 1120 }; 1121 const fallbackAccountId = 1122 d.accountId || 1123 d.activeAccount || 1124 d.active_account || 1125 resolveAccountIdFromUrl(d.redirectionPath || notificationData.url || self.location.origin); 1126 1127 const target = clientList.find((c) => c.url.startsWith(self.location.origin)); 1128 if (target) { 1129 // Focus the existing tab and let the app handle navigation via SW_CALL_ACCEPTED. 1130 // The app builds the correct meet URL (with accountId, redirectionPath, etc.) 1131 // and opens it in a new tab â more reliable than SW-side navigate(). 1132 await target.focus(); 1133 target.postMessage({ type: "SW_CALL_ACCEPTED", payload: notificationData.payload }); 1134 Logger.info("Sent SW_CALL_ACCEPTED to app for meet join"); 1135 } else { 1136 // No open tab â open the meet join URL directly in a new window 1137 const joinUrl = d.roomId 1138 ? `/meet/u/${encodeURIComponent(fallbackAccountId)}/join_meet?roomId=${encodeURIComponent(d.roomId)}&autoJoin=true${d.chatId ? `&chatId=${encodeURIComponent(d.chatId)}` : ""}` 1139 : (notificationData.url || "/meet"); 1140 await clients.openWindow(joinUrl); 1141 Logger.info("Opened new meet window", { joinUrl }); 1142 } 1143 }) 1144 ); 1145 return; 1146 } else if (action === "view") { 1147 // View action â same Chrome-native-auto-display fallback as above. 1148 targetUrl = notificationData.url || notificationData.redirectionPath || "/"; 1149 } 1150 1151 // Handle navigation for non-meet actions 1152 event.waitUntil( 1153 clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => { 1154 // Try to find an already open tab with matching URL 1155 for (const client of clientList) { 1156 const clientUrl = new URL(client.url); 1157 const targetUrlObj = new URL(targetUrl, self.location.origin); 1158 1159 if (clientUrl.pathname === targetUrlObj.pathname) { 1160 const needsNavigation = clientUrl.href !== targetUrlObj.href; 1161 let navigatedViaSw = false; 1162 1163 if (needsNavigation && typeof client.navigate === "function") { 1164 try { 1165 await client.navigate(targetUrlObj.href); 1166 navigatedViaSw = true; 1167 } catch (err) { 1168 Logger.warn("client.navigate() failed, falling back to postMessage:", err); 1169 } 1170 } 1171 1172 await client.focus(); 1173 1174 // Send message to stop sound if playing 1175 client.postMessage({ 1176 type: "STOP_NOTIFICATION_SOUND" 1177 }); 1178 1179 // Fallback for browsers without WindowClient.navigate() (Firefox, Safari) 1180 // or where it silently failed â the app listens for this and does 1181 // window.location.href = path, so the redirect still happens. 1182 if (needsNavigation && !navigatedViaSw) { 1183 client.postMessage({ 1184 type: "NAVIGATE", 1185 path: targetUrl, 1186 action: action, 1187 }); 1188 } 1189 1190 Logger.info("Focused existing tab"); 1191 return client; 1192 } 1193 } 1194 1195 // If no matching tab, focus any tab from our origin 1196 const sameOriginClient = clientList.find((client) => 1197 client.url.startsWith(self.location.origin) 1198 ); 1199 1200 if (sameOriginClient) { 1201 // This is the "user is on a completely different product's tab" case 1202 // (e.g. on Drive, notification is for Chat) â no pathname matched above. 1203 // try/catch is required: without it, a thrown/rejected navigate() here 1204 // aborts this whole async callback, so focus() and the postMessages 1205 // below never run either â the click silently does nothing. 1206 if (typeof sameOriginClient.navigate === "function") { 1207 try { 1208 await sameOriginClient.navigate(new URL(targetUrl, self.location.origin).href); 1209 } catch (err) {
1210 Logger.warn("client.navigate() failed on cross-product redirect:", err); 1211 } 1212 } 1213 1214 await sameOriginClient.focus(); 1215 1216 // Send navigation message 1217 sameOriginClient.postMessage({ 1218 type: "STOP_NOTIFICATION_SOUND" 1219 }); 1220 1221 // ALWAYS also send this, even after a navigate() that didn't throw â 1222 // on Firefox specifically, navigating to a tab that's on a different 1223 // product/page has been observed to resolve without error yet not 1224 // actually move the page. The app's own NAVIGATE handler 1225 // (window.location.href) is a safe, idempotent backstop: harmless if 1226 // navigate() already worked, the only real fix when it silently didn't. 1227 sameOriginClient.postMessage({ 1228 type: "NAVIGATE", 1229 path: targetUrl, 1230 action: action, 1231 }); 1232 1233 Logger.info("Navigated existing tab"); 1234 return sameOriginClient; 1235 } 1236 1237 // As last resort, open a new window 1238 Logger.info("Opening new window"); 1239 return clients.openWindow(targetUrl); 1240 }) 1241 ); 1242}); 1243 1244// ================================================================ 1245// Message Handler (Communication with Main Thread) 1246// ================================================================ 1247 1248self.addEventListener("message", async (event) => { 1249 const messageType = event.data?.type; 1250 1251 Logger.debug("Message received:", messageType, event.data); 1252 1253 switch (messageType) { 1254 case "RESET_BADGE": 1255 // Reset notification badge 1256 await badgeManager.reset(); 1257 break; 1258 1259 case "GET_NOTIFICATION_COUNT": 1260 // Get unread notification count 1261 const notifications = await storage.getAll(CONFIG.STORE_NOTIFICATIONS); 1262 const unreadCount = notifications.filter(n => !n.read).length; 1263 1264 event.ports[0].postMessage({ 1265 type: "NOTIFICATION_COUNT", 1266 count: unreadCount, 1267 }); 1268 break; 1269 1270 case "CLEAR_NOTIFICATIONS": 1271 // Clear notification history 1272 await storage.clear(CONFIG.STORE_NOTIFICATIONS); 1273 await badgeManager.reset(); 1274 Logger.info("Notification history cleared"); 1275 break; 1276 1277 case "GET_ANALYTICS": 1278 // Get analytics data 1279 const analyticsData = await storage.getAll(CONFIG.STORE_ANALYTICS); 1280 event.ports[0].postMessage({ 1281 type: "ANALYTICS_DATA", 1282 data: analyticsData, 1283 }); 1284 break; 1285 1286 case "ENABLE_DEBUG": 1287 // Enable debug mode 1288 CONFIG.DEBUG = true; 1289 Logger.info("Debug mode enabled"); 1290 break; 1291 1292 case "DISABLE_DEBUG": 1293 // Disable debug mode 1294 CONFIG.DEBUG = false; 1295 Logger.info("Debug mode disabled"); 1296 break; 1297 1298 case "DISMISS_CALL_NOTIFICATION": { 1299 // Close the OS/browser notification for a specific call (sent when user 1300 // accepts, declines, or the call ends via the in-app popup). 1301 const dismissRoomId = event.data?.roomId; 1302 Logger.info("ð [Dismiss] Dismissing call notification for roomId:", dismissRoomId); 1303 try { 1304 const allNotifications = await self.registration.getNotifications(); 1305 let dismissed = 0; 1306 allNotifications.forEach((notif) => { 1307 // Only target meet notifications 1308 if (notif.data?.product !== "meet") return; 1309 // If a specific roomId was provided, match it; otherwise close all meet notifications 1310 const notifRoomId = notif.data?.payload?.data?.roomId; 1311 if (!dismissRoomId || !notifRoomId || String(notifRoomId) === String(dismissRoomId)) { 1312 notif.close(); 1313 dismissed++; 1314 } 1315 }); 1316 Logger.info(`ð [Dismiss] Closed ${dismissed} call notification(s) for roomId: ${dismissRoomId}`); 1317 await analytics.track("call_notification_dismissed", { roomId: dismissRoomId, dismissed }); 1318 } catch (error) { 1319 Logger.error("ð [Dismiss] Failed to dismiss call notification:", error); 1320 } 1321 break; 1322 } 1323 1324 case "STOP_NOTIFICATION_SOUND": 1325 // Broadcast stop sound message to all clients 1326 Logger.info("ð [Sound] Received request to stop notification sounds");
1327 try { 1328 const clientsList = await clients.matchAll({ type: "window", includeUncontrolled: true }); 1329 1330 Logger.info(`ð [Sound] Broadcasting stop message to ${clientsList.length} client(s)`); 1331 1332 clientsList.forEach((client) => { 1333 client.postMessage({ 1334 type: "STOP_NOTIFICATION_SOUND", 1335 timestamp: Date.now() 1336 }); 1337 }); 1338 1339 Logger.info("ð [Sound] â Stop sound message broadcasted to all clients"); 1340 1341 await analytics.track("notification_sound_stopped", { 1342 clientCount: clientsList.length, 1343 timestamp: Date.now() 1344 }); 1345 } catch (error) { 1346 Logger.error("ð [Sound] Failed to broadcast stop sound message:", error); 1347 } 1348 break; 1349 1350 default: 1351 Logger.warn("Unknown message type:", messageType); 1352 } 1353}); 1354 1355// ================================================================ 1356// Background Sync (for offline notification processing) 1357// ================================================================ 1358 1359self.addEventListener("sync", async (event) => {
1360 Logger.info("Background sync triggered:", event.tag); 1361 1362 if (event.tag === "sync-notifications") { 1363 event.waitUntil( 1364 (async () => { 1365 try { 1366 const notifications = await storage.getAll(CONFIG.STORE_NOTIFICATIONS); 1367 const unprocessed = notifications.filter(n => !n.processed); 1368 1369 Logger.info(`Processing ${unprocessed.length} unprocessed notifications`); 1370 1371 for (const notification of unprocessed) { 1372 // Process notification 1373 const product = notification.product || "default"; 1374 1375 if (product === "meet") { 1376 await processMeetNotification(notification.payload); 1377 } else { 1378 await processDefaultNotification(notification.payload); 1379 } 1380 1381 // Mark as processed 1382 notification.processed = true; 1383 await storage.set(CONFIG.STORE_NOTIFICATIONS, notification); 1384 } 1385 1386 await analytics.track("sync_notifications_completed", { 1387 count: unprocessed.length 1388 }); 1389 } catch (error) { 1390 Logger.error("Background sync failed:", error); 1391 } 1392 })() 1393 ); 1394 } 1395}); 1396 1397// ================================================================ 1398// Error Handler 1399// ================================================================ 1400 1401self.addEventListener("error", (event) => { 1402 Logger.error("Service Worker error:", event.error); 1403 analytics.track("sw_error", { 1404 message: event.error?.message, 1405 stack: event.error?.stack, 1406 }); 1407}); 1408 1409self.addEventListener("unhandledrejection", (event) => { 1410 const msg = event.reason?.message || String(event.reason || ""); 1411 // Firebase SDK internally tries to cache notification resources; suppress 1412 // the harmless 206 (Partial Content) cache error it occasionally throws. 1413 if (msg.includes("206") || msg.toLowerCase().includes("partial response")) { 1414 event.preventDefault(); 1415 Logger.warn("Suppressed 206 partial-response cache error (Firebase SDK internal)"); 1416 return; 1417 } 1418 Logger.error("Unhandled promise rejection:", event.reason); 1419 analytics.track("sw_unhandled_rejection", { 1420 reason: event.reason, 1421 }); 1422}); 1423 1424// ================================================================ 1425// Initialization 1426// ================================================================ 1427 1428Logger.info("=".repeat(60)); 1429Logger.info("Firebase Cloud Messaging Service Worker - Advanced Version"); 1430Logger.info("Features: Push Notifications, Auto Logout, Analytics, Rate Limiting"); 1431Logger.info("=".repeat(60));
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.