// Service Worker version const CACHE_VERSION = 'v1.0.0'; const CACHE_NAME = `game-timer-${CACHE_VERSION}`; // Files to cache const CACHE_FILES = [ '/', '/index.html', '/app.js', '/audio.js', '/deeplinks.js', '/styles.css', '/manifest.json', '/icons/android-chrome-192x192.png', '/icons/android-chrome-512x512.png', '/icons/apple-touch-icon.png', '/icons/favicon-32x32.png', '/icons/favicon-16x16.png' ]; // Valid deep link actions const VALID_ACTIONS = ['start', 'pause', 'toggle', 'nextplayer', 'reset']; // 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(); }) ); }); // Fetch event - Serve from cache, fallback to network self.addEventListener('fetch', event => { console.log('[ServiceWorker] Fetch', event.request.url); // For navigation requests that include our deep link parameters, // skip the cache and go straight to network if (event.request.mode === 'navigate') { const url = new URL(event.request.url); // Check if request has action parameter or hash if (url.searchParams.has('action') || url.hash.includes('action=')) { console.log('[ServiceWorker] Processing deep link navigation'); // Verify the action is valid const action = url.searchParams.get('action') || new URLSearchParams(url.hash.substring(1)).get('action'); if (action && VALID_ACTIONS.includes(action)) { console.log('[ServiceWorker] Valid action found:', action); // For navigation requests with valid actions, let the request go through // so our app can handle the deep link return; } } } event.respondWith( caches.match(event.request) .then(response => { return response || fetch(event.request) .then(res => { // Check if we should cache this response if (shouldCacheResponse(event.request, res)) { return caches.open(CACHE_NAME) .then(cache => { console.log('[ServiceWorker] Caching new resource:', event.request.url); cache.put(event.request, res.clone()); return res; }); } return res; }); }) .catch(error => { console.log('[ServiceWorker] Fetch failed; returning offline page', error); // You could return a custom offline page here }) ); }); // 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; } // Handle deep links from other apps (including Flic) self.addEventListener('message', event => { if (event.data && event.data.type === 'PROCESS_ACTION') { const action = event.data.action; console.log('[ServiceWorker] Received action message:', action); // Validate the action if (!VALID_ACTIONS.includes(action)) { console.warn('[ServiceWorker] Invalid action received:', action); return; } // Broadcast the action to all clients self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'ACTION', action: action }); }); }); } }); self.addEventListener('push', event => { console.log('[ServiceWorker] Push received'); let pushData = { title: 'Flic Action', body: 'Button pressed!', data: { action: 'Unknown', button: 'Unknown' } // Default data }; // --- 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, // IMPORTANT: Extract the action details sent from your backend data: parsedData.data || pushData.data // Expecting { action: 'SingleClick', button: '...' } }; } 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, // e.g., 'SingleClick', 'DoubleClick', 'Hold' button: pushData.data.button // e.g., the button serial }; // 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, we MUST show a notification return self.registration.showNotification(pushData.title, { body: pushData.body, icon: '/icons/icon-192x192.png', // Optional: path to an icon data: pushData.data // Pass data if needed when notification is clicked }); } // Post message to each client let messageSent = false; clientList.forEach(client => { console.log(`[ServiceWorker] Posting message to client: ${client.id}`, messagePayload); client.postMessage(messagePayload); messageSent = true; // Mark that we at least tried to send a message }); // Decide whether to still show a notification even if a window is open. // Generally good practice unless you are SURE the app will handle it visibly. // You might choose *not* to show a notification if a client was found and focused. // For simplicity here, we'll still show one. Adjust as needed. if (!messageSent) { // Only show notification if no message was sent? Or always show? return self.registration.showNotification(pushData.title, { body: pushData.body, icon: '/icons/icon-192x192.png', data: pushData.data }); } }) ); // --- Show a notification (Important!) --- // Push notifications generally REQUIRE showing a notification to the user // unless the PWA is already in the foreground AND handles the event visually. // It's safer to always show one unless you have complex foreground detection. /* This part is now handled inside the clients.matchAll promise */ /* const notificationOptions = { body: pushData.body, icon: '/icons/icon-192x192.png', // Optional: path to an icon data: pushData.data // Attach data if needed when notification is clicked }; event.waitUntil( self.registration.showNotification(pushData.title, notificationOptions) ); */ }); // 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('/'); } }) ); });