// Service Worker version const CACHE_VERSION = 'v1.0.2'; const CACHE_NAME = `game-timer-${CACHE_VERSION}`; // Store last battery warning timestamp to prevent repeated notifications let lastBatteryWarningTimestamp = 0; const FOUR_HOURS_MS = 4 * 60 * 60 * 1000; // 4 hours in milliseconds // Settings cache for storing timestamps const SETTINGS_CACHE = 'settings-cache-v1'; const TIMESTAMP_URL = new Request('/_timestamp/battery-warning'); // Function to load the timestamp from cache async function loadTimestamp() { try { const cache = await caches.open(SETTINGS_CACHE); const response = await cache.match(TIMESTAMP_URL); if (response) { const data = await response.json(); lastBatteryWarningTimestamp = data.timestamp; console.log('[ServiceWorker] Loaded battery warning timestamp:', new Date(lastBatteryWarningTimestamp)); } else { console.log('[ServiceWorker] No saved timestamp found'); } return true; } catch (error) { console.error('[ServiceWorker] Error loading timestamp:', error); return false; } } // Function to save the timestamp to cache async function saveTimestamp(timestamp) { try { const cache = await caches.open(SETTINGS_CACHE); const response = new Response(JSON.stringify({ timestamp }), { headers: { 'Content-Type': 'application/json' } }); await cache.put(TIMESTAMP_URL, response); console.log('[ServiceWorker] Saved battery warning timestamp:', new Date(timestamp)); return true; } catch (error) { console.error('[ServiceWorker] Error saving timestamp:', error); return false; } } // Initialize and load data when the service worker starts loadTimestamp().then(success => { console.log('[ServiceWorker] Timestamp loading ' + (success ? 'successful' : 'failed')); }); // Files to cache const CACHE_FILES = [ '/', '/sw.js', '/index.html', '/manifest.json', '/css/styles.css', '/favicon.ico', '/icons/android-chrome-192x192.png', '/icons/android-chrome-512x512.png', '/icons/apple-touch-icon.png', '/icons/favicon-32x32.png', '/icons/favicon-16x16.png', '/js/app.js', '/js/config.js', '/js/env-loader.js', '/js/ui/audio.js', '/js/ui/camera.js', '/js/ui/ui.js', '/js/core/state.js', '/js/core/timer.js', '/js/core/gameActions.js', '/js/core/playerManager.js', '/js/core/eventHandlers.js', '/js/services/pushFlicIntegration.js', '/js/services/serviceWorkerManager.js' ]; // Install event - Cache files self.addEventListener('install', event => { console.log('[ServiceWorker] Install'); event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('[ServiceWorker] Caching app shell'); return cache.addAll(CACHE_FILES); }) .then(() => { console.log('[ServiceWorker] Skip waiting on install'); return self.skipWaiting(); }) ); }); // Activate event - Clean old caches self.addEventListener('activate', event => { console.log('[ServiceWorker] Activate'); event.waitUntil( caches.keys().then(keyList => { return Promise.all(keyList.map(key => { if (key !== CACHE_NAME) { console.log('[ServiceWorker] Removing old cache', key); return caches.delete(key); } })); }) .then(() => { console.log('[ServiceWorker] Claiming clients'); return self.clients.claim(); }) ); }); // Helper function to determine if a response should be cached function shouldCacheResponse(request, response) { // Only cache GET requests if (request.method !== 'GET') return false; // Don't cache errors if (!response || response.status !== 200) return false; // Check if URL should be cached const url = new URL(request.url); // Don't cache query parameters (except common ones for content) if (url.search && !url.search.match(/\?(v|version|cache)=/)) return false; return true; } self.addEventListener('push', event => { console.log('[ServiceWorker] Push received'); let pushData = { title: 'Flic Action', body: 'Button pressed!', data: { action: 'Unknown', button: 'Unknown', batteryLevel: undefined, timestamp: new Date().toISOString() } }; // --- Attempt to parse data payload --- if (event.data) { try { const parsedData = event.data.json(); console.log('[ServiceWorker] Push data:', parsedData); // Use parsed data for notification and message pushData = { title: parsedData.title || pushData.title, body: parsedData.body || pushData.body, data: parsedData.data || pushData.data // Expecting { action: 'SingleClick', button: 'game-button', batteryLevel: 75 } }; // Ensure all required fields are present in data pushData.data = pushData.data || {}; if (!pushData.data.timestamp) { pushData.data.timestamp = new Date().toISOString(); } } catch (e) { console.error('[ServiceWorker] Error parsing push data:', e); // Use default notification if parsing fails pushData.body = event.data.text() || pushData.body; // Fallback to text } } else { console.log('[ServiceWorker] Push event but no data'); } // --- Send message to client(s) --- const messagePayload = { type: 'flic-action', // Custom message type action: pushData.data.action || 'Unknown', // e.g., 'SingleClick', 'DoubleClick', 'Hold' button: pushData.data.button || 'Unknown', // e.g., the button name timestamp: pushData.data.timestamp || new Date().toISOString(), // e.g., the timestamp of the action batteryLevel: pushData.data.batteryLevel // e.g., the battery level percentage }; console.log('[ServiceWorker] Preparing message payload:', messagePayload); // Check if this is a low battery alert that needs a notification const batteryLevel = messagePayload.batteryLevel; const isBatteryLow = batteryLevel !== undefined && batteryLevel < 50; // Use the same threshold as in the app // Determine if we should show a battery notification (throttle to once every 4 hours) let shouldShowBatteryNotification = false; if (isBatteryLow) { const now = Date.now(); if (now - lastBatteryWarningTimestamp > FOUR_HOURS_MS) { // It's been more than 4 hours since the last battery notification console.log(`[ServiceWorker] Low battery (${batteryLevel}%) - showing notification`); lastBatteryWarningTimestamp = now; // Save the timestamp to cache saveTimestamp(now).catch(error => { console.warn('[ServiceWorker] Failed to save battery warning timestamp:', error); }); shouldShowBatteryNotification = true; // Change notification title/body for battery alerts pushData.title = 'Flic Button Low Battery'; pushData.body = `Battery level is ${batteryLevel}%. Please replace batteries soon.`; } else { console.log(`[ServiceWorker] Low battery (${batteryLevel}%) - suppressing notification (shown recently)`); } } // Send message to all open PWA windows controlled by this SW event.waitUntil( self.clients.matchAll({ type: 'window', // Only target window clients includeUncontrolled: true // Include clients that might not be fully controlled yet }).then(clientList => { if (!clientList || clientList.length === 0) { console.log('[ServiceWorker] No client windows found to send message to.'); // If no window is open AND this is a battery alert that should be shown, show a notification if (shouldShowBatteryNotification) { return self.registration.showNotification(pushData.title, { body: pushData.body, icon: '/icons/android-chrome-192x192.png', // Updated path data: pushData.data // Pass data if needed when notification is clicked }); } // Otherwise, don't show notification for regular button presses return Promise.resolve(); } // Post message to each client with improved reliability let messageSent = false; const sendPromises = clientList.map(client => { console.log(`[ServiceWorker] Posting message to client: ${client.id}`, messagePayload); try { // Try to send the message and mark it as sent client.postMessage(messagePayload); messageSent = true; // Just return true to indicate message was sent return Promise.resolve(true); } catch (error) { console.error('[ServiceWorker] Error posting message to client:', error); return Promise.resolve(false); } }); return Promise.all(sendPromises).then(() => { // Only show a notification if this is a battery alert that should be shown if (shouldShowBatteryNotification) { return self.registration.showNotification(pushData.title, { body: pushData.body, icon: '/icons/android-chrome-192x192.png', data: pushData.data }); } // For regular button presses or throttled battery alerts, don't show notifications return Promise.resolve(); }); }) ); }); // Listen for messages from client self.addEventListener('message', event => { const message = event.data; if (!message || typeof message !== 'object') { return; } // Handle get timestamp request if (message.type === 'get-battery-timestamp') { console.log('[ServiceWorker] Client requested battery warning timestamp'); event.source.postMessage({ type: 'battery-timestamp', timestamp: lastBatteryWarningTimestamp }); } // Handle update timestamp request if (message.type === 'update-battery-timestamp' && message.timestamp) { console.log('[ServiceWorker] Updating battery warning timestamp to:', new Date(message.timestamp)); lastBatteryWarningTimestamp = message.timestamp; saveTimestamp(message.timestamp).catch(error => { console.warn('[ServiceWorker] Failed to save updated battery warning timestamp:', error); }); } }); // This helps with navigation after app is installed self.addEventListener('notificationclick', event => { console.log('[ServiceWorker] Notification click received'); event.notification.close(); // Handle the notification click event.waitUntil( self.clients.matchAll({ type: 'window' }) .then(clientList => { for (const client of clientList) { if (client.url.startsWith(self.location.origin) && 'focus' in client) { return client.focus(); } } if (self.clients.openWindow) { return self.clients.openWindow('/'); } }) ); });