/// /// /// declare const self: ServiceWorkerGlobalScope; export {}; // Notification tag prefix for VibeTunnel notifications const NOTIFICATION_TAG_PREFIX = 'vibetunnel-'; // Types for push notification payloads interface SessionExitData { type: 'session-exit'; sessionId: string; sessionName?: string; command?: string; exitCode: number; duration?: number; timestamp: number; } interface SessionStartData { type: 'session-start'; sessionId: string; sessionName?: string; command?: string; timestamp: number; } interface SessionErrorData { type: 'session-error'; sessionId: string; sessionName?: string; command?: string; error: string; timestamp: number; } interface SystemAlertData { type: 'system-alert'; message: string; level: 'info' | 'warning' | 'error'; timestamp: number; } type NotificationData = SessionExitData | SessionStartData | SessionErrorData | SystemAlertData; interface PushNotificationPayload { title: string; body: string; icon?: string; badge?: string; data: NotificationData; actions?: Array<{ action: string; title: string; icon?: string; }>; tag?: string; requireInteraction?: boolean; } // Install event self.addEventListener('install', (_event: ExtendableEvent) => { console.log('[SW] Installing service worker'); // Force activation of new service worker self.skipWaiting(); }); // Activate event self.addEventListener('activate', (event: ExtendableEvent) => { console.log('[SW] Activating service worker'); event.waitUntil( // Take control of all pages self.clients.claim() ); }); // Push event - handle incoming push notifications self.addEventListener('push', (event: PushEvent) => { console.log('[SW] Push event received'); if (!event.data) { console.warn('[SW] Push event has no data'); return; } let payload: PushNotificationPayload; try { payload = event.data.json(); } catch (error) { console.error('[SW] Failed to parse push payload:', error); return; } event.waitUntil(handlePushNotification(payload)); }); // Notification click event - handle user interactions self.addEventListener('notificationclick', (event: NotificationEvent) => { console.log('[SW] Notification clicked:', event.notification.tag); event.notification.close(); const data = event.notification.data as NotificationData; event.waitUntil(handleNotificationClick(event.action, data)); }); // Notification close event - track dismissals self.addEventListener('notificationclose', (event: NotificationEvent) => { console.log('[SW] Notification closed:', event.notification.tag); const data = event.notification.data as NotificationData; // Optional: Send analytics or cleanup if (data.type === 'session-exit' || data.type === 'session-error') { // Could track notification dismissal metrics } }); // No background sync needed async function handlePushNotification(payload: PushNotificationPayload): Promise { const { title, body, icon, badge, data, actions, tag, requireInteraction } = payload; try { // Create notification options const notificationOptions: NotificationOptions = { body, icon: icon || '/apple-touch-icon.png', badge: badge || '/favicon-32.png', data, tag: tag || `${NOTIFICATION_TAG_PREFIX}${data.type}-${Date.now()}`, requireInteraction: requireInteraction || data.type === 'session-error', silent: false, // @ts-expect-error - renotify is a valid option but not in TypeScript types renotify: true, actions: actions || getDefaultActions(data), timestamp: data.timestamp, }; // Add vibration pattern for mobile devices if ('vibrate' in navigator) { // @ts-expect-error - vibrate is a valid option but not in TypeScript types notificationOptions.vibrate = getVibrationPattern(data.type); } // Show the notification await self.registration.showNotification(title, notificationOptions); console.log('[SW] Notification shown:', title); } catch (error) { console.error('[SW] Failed to show notification:', error); } } interface NotificationAction { action: string; title: string; } function getDefaultActions(data: NotificationData): NotificationAction[] { const baseActions: NotificationAction[] = [ { action: 'dismiss', title: 'Dismiss', }, ]; switch (data.type) { case 'session-exit': case 'session-error': case 'session-start': { return [ { action: 'view-session', title: 'View Session', }, ...baseActions, ]; } case 'system-alert': { return [ { action: 'view-logs', title: 'View Logs', }, ...baseActions, ]; } default: return baseActions; } } function getVibrationPattern(notificationType: string): number[] { switch (notificationType) { case 'session-error': return [200, 100, 200, 100, 200]; // Urgent pattern case 'session-exit': return [100, 50, 100]; // Short notification case 'session-start': return [50]; // Very brief case 'system-alert': return [150, 75, 150]; // Moderate pattern default: return [100]; // Default brief vibration } } async function handleNotificationClick(action: string, data: NotificationData): Promise { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true, }); // Try to focus existing window first for (const client of clients) { if (client.url.includes(self.location.origin)) { try { await client.focus(); // Send action to the client client.postMessage({ type: 'notification-action', action, data, }); return; } catch (error) { console.warn('[SW] Failed to focus client:', error); } } } // No existing window, open a new one let url = self.location.origin; switch (action) { case 'view-session': { if ( data.type === 'session-exit' || data.type === 'session-error' || data.type === 'session-start' ) { url += `/?session=${data.sessionId}`; } break; } case 'view-logs': { url += '/logs'; break; } default: // Just open the main page break; } try { await self.clients.openWindow(url); } catch (error) { console.error('[SW] Failed to open window:', error); } } // No offline notification handling needed // No fetch event handler needed - we don't cache anything // Message handler for communication with main thread self.addEventListener('message', (event: ExtendableMessageEvent) => { const { data } = event; switch (data.type) { case 'CLEAR_NOTIFICATIONS': { // Clear all VibeTunnel notifications clearAllNotifications(); break; } case 'SKIP_WAITING': { self.skipWaiting(); break; } } }); // No queueing needed async function clearAllNotifications(): Promise { try { const notifications = await self.registration.getNotifications(); for (const notification of notifications) { if (notification.tag?.startsWith(NOTIFICATION_TAG_PREFIX)) { notification.close(); } } console.log('[SW] Cleared all VibeTunnel notifications'); } catch (error) { console.error('[SW] Failed to clear notifications:', error); } } console.log('[SW] Service worker loaded');