mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-14 12:46:05 +00:00
310 lines
7.5 KiB
TypeScript
310 lines
7.5 KiB
TypeScript
/// <reference no-default-lib="true" />
|
|
/// <reference lib="es2020" />
|
|
/// <reference lib="webworker" />
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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');
|