Notification fix attempt

This commit is contained in:
Peter Steinberger 2025-07-29 09:46:02 +02:00
parent bfeb94d972
commit 89b30d0d8c
6 changed files with 395 additions and 108 deletions

View file

@ -41,6 +41,7 @@ import type { GitNotificationHandler } from './components/git-notification-handl
import { authClient } from './services/auth-client.js';
import { bufferSubscriptionService } from './services/buffer-subscription-service.js';
import { getControlEventService } from './services/control-event-service.js';
import { notificationEventService } from './services/notification-event-service.js';
import { pushNotificationService } from './services/push-notification-service.js';
const logger = createLogger('app');
@ -461,6 +462,19 @@ export class VibeTunnelApp extends LitElement {
private async handleAuthSuccess() {
logger.log('✅ Authentication successful');
// If already authenticated (e.g., in no-auth mode), don't re-initialize
if (this.isAuthenticated && this.initialLoadComplete) {
logger.debug('Already authenticated and initialized, skipping re-initialization');
return;
}
// If services are already being initialized, skip
if (this.servicesInitialized || this.isAuthenticated) {
logger.debug('Services already initialized or being initialized, skipping');
return;
}
this.isAuthenticated = true;
this.currentView = 'list';
await this.initializeServices(false); // Initialize services after auth (auth is enabled)
@ -482,23 +496,33 @@ export class VibeTunnelApp extends LitElement {
}
}
private async initializeServices(noAuthEnabled = false) {
private servicesInitialized = false;
private async initializeServices(_noAuthEnabled = false) {
if (this.servicesInitialized) {
logger.debug('Services already initialized, skipping');
return;
}
logger.log('🚀 Initializing services...');
try {
// Initialize buffer subscription service for WebSocket connections
await bufferSubscriptionService.initialize();
// Initialize push notification service only if auth is enabled
if (!noAuthEnabled) {
await pushNotificationService.initialize();
} else {
logger.log('⏭️ Skipping push notification service initialization (no-auth mode)');
}
// Initialize push notification service always
// It handles its own permission checks and user preferences
await pushNotificationService.initialize();
// Initialize control event service for real-time notifications
this.controlEventService = getControlEventService(authClient);
this.controlEventService.connect();
// Initialize notification event service to monitor /api/events SSE connection
// This is used by the Mac app for notifications
notificationEventService.setAuthClient(authClient);
await notificationEventService.connect();
this.servicesInitialized = true;
logger.log('✅ Services initialized successfully');
} catch (error) {
logger.error('❌ Failed to initialize services:', error);
@ -1816,8 +1840,16 @@ export class VibeTunnelApp extends LitElement {
.visible=${this.showSettings}
.authClient=${authClient}
@close=${this.handleCloseSettings}
@notifications-enabled=${() => this.showSuccess('Notifications enabled')}
@notifications-disabled=${() => this.showSuccess('Notifications disabled')}
@notifications-enabled=${async () => {
this.showSuccess('Notifications enabled');
// Reconnect SSE when notifications are enabled
await notificationEventService.connect();
}}
@notifications-disabled=${() => {
this.showSuccess('Notifications disabled');
// Disconnect SSE when notifications are disabled
notificationEventService.disconnect();
}}
@success=${(e: CustomEvent) => this.showSuccess(e.detail)}
@error=${(e: CustomEvent) => this.showError(e.detail)}
></vt-settings>

View file

@ -1,12 +1,9 @@
import { html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import {
type PushSubscription,
pushNotificationService,
} from '../services/push-notification-service.js';
import { notificationEventService } from '../services/notification-event-service.js';
import { createLogger } from '../utils/logger.js';
const _logger = createLogger('notification-status');
const logger = createLogger('notification-status');
@customElement('notification-status')
export class NotificationStatus extends LitElement {
@ -15,12 +12,9 @@ export class NotificationStatus extends LitElement {
return this;
}
@state() private permission: NotificationPermission = 'default';
@state() private subscription: PushSubscription | null = null;
@state() private isSupported = false;
@state() private isSSEConnected = false;
private permissionChangeUnsubscribe?: () => void;
private subscriptionChangeUnsubscribe?: () => void;
private connectionStateUnsubscribe?: () => void;
connectedCallback() {
super.connectedCallback();
@ -29,35 +23,21 @@ export class NotificationStatus extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
if (this.permissionChangeUnsubscribe) {
this.permissionChangeUnsubscribe();
}
if (this.subscriptionChangeUnsubscribe) {
this.subscriptionChangeUnsubscribe();
if (this.connectionStateUnsubscribe) {
this.connectionStateUnsubscribe();
}
}
private async initializeComponent(): Promise<void> {
this.isSupported = pushNotificationService.isSupported();
private initializeComponent(): void {
// Get initial connection state
this.isSSEConnected = notificationEventService.getConnectionStatus();
logger.debug('Initial SSE connection status:', this.isSSEConnected);
if (!this.isSupported) {
return;
}
// Wait for the push notification service to be fully initialized
await pushNotificationService.waitForInitialization();
this.permission = pushNotificationService.getPermission();
this.subscription = pushNotificationService.getSubscription();
// Listen for changes
this.permissionChangeUnsubscribe = pushNotificationService.onPermissionChange((permission) => {
this.permission = permission;
});
this.subscriptionChangeUnsubscribe = pushNotificationService.onSubscriptionChange(
(subscription) => {
this.subscription = subscription;
// Listen for connection state changes
this.connectionStateUnsubscribe = notificationEventService.onConnectionStateChange(
(connected) => {
logger.log(`SSE connection state changed: ${connected ? 'connected' : 'disconnected'}`);
this.isSSEConnected = connected;
}
);
}
@ -67,27 +47,18 @@ export class NotificationStatus extends LitElement {
}
private getStatusConfig() {
// Green when notifications are enabled (permission granted AND subscription active)
if (this.permission === 'granted' && this.subscription) {
// Green when SSE is connected (Mac app notifications are working)
if (this.isSSEConnected) {
return {
color: 'text-status-success',
tooltip: 'Settings (Notifications enabled)',
tooltip: 'Settings (Notifications connected)',
};
}
// Default color for all other cases (not red anymore)
let tooltip = 'Settings (Notifications disabled)';
if (!this.isSupported) {
tooltip = 'Settings (Notifications not supported)';
} else if (this.permission === 'denied') {
tooltip = 'Settings (Notifications blocked)';
} else if (!this.subscription) {
tooltip = 'Settings (Notifications not subscribed)';
}
// Default color when SSE is not connected
return {
color: 'text-muted',
tooltip,
tooltip: 'Settings (Notifications disconnected)',
};
}

View file

@ -0,0 +1,295 @@
import { createLogger } from '../utils/logger.js';
import type { AuthClient } from './auth-client.js';
import { pushNotificationService } from './push-notification-service.js';
const logger = createLogger('notification-event-service');
type ConnectionStateHandler = (connected: boolean) => void;
type EventHandler = (data: unknown) => void;
export class NotificationEventService {
private eventSource: EventSource | null = null;
private isConnected = false;
private connectionStateHandlers: Set<ConnectionStateHandler> = new Set();
private eventListeners: Map<string, Set<EventHandler>> = new Map();
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectDelay = 1000; // Start with 1 second
private maxReconnectDelay = 30000; // Max 30 seconds
private shouldReconnect = true;
private isConnecting = false;
constructor(private authClient?: AuthClient) {}
/**
* Connect to the notification event stream
*/
async connect(): Promise<void> {
if (this.eventSource || this.isConnecting) {
logger.debug('Already connected or connecting to notification event stream');
return;
}
// Skip notification check in no-auth mode - always connect
const isNoAuth = !this.authClient || !this.authClient.getAuthHeader().Authorization;
if (!isNoAuth) {
// Check if notifications are enabled before connecting
try {
logger.debug('Checking notification preferences...');
await pushNotificationService.waitForInitialization();
const preferences = await pushNotificationService.loadPreferences();
logger.debug('Loaded notification preferences:', preferences);
if (!preferences.enabled) {
logger.debug('Notifications are disabled, not connecting to SSE');
this.isConnecting = false;
return;
}
} catch (error) {
logger.warn('Could not check notification preferences:', error);
// Continue anyway - let the user enable notifications later
}
} else {
logger.debug('No-auth mode - connecting to SSE without checking preferences');
}
this.isConnecting = true;
logger.log('Connecting to notification event stream...');
let url = '/api/events';
// EventSource doesn't support custom headers in browsers
// In no-auth mode, we don't need to add a token
if (!isNoAuth && this.authClient) {
const authHeader = this.authClient.getAuthHeader();
if (authHeader.Authorization?.startsWith('Bearer ')) {
const token = authHeader.Authorization.substring(7);
url = `${url}?token=${encodeURIComponent(token)}`;
logger.debug('Added auth token to EventSource URL');
}
} else {
logger.debug('No auth mode - connecting without token');
}
this.eventSource = new EventSource(url);
// Add readyState logging
logger.log(
`EventSource created with URL: ${url}, readyState: ${this.eventSource.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSED)`
);
this.eventSource.onopen = () => {
logger.log('✅ SSE onopen event fired - connection established');
this.isConnected = true;
this.isConnecting = false;
this.reconnectDelay = 1000; // Reset delay on successful connection
this.notifyConnectionState(true);
};
// Add multiple timeouts to track connection state
setTimeout(() => {
logger.log(
`SSE state after 100ms: readyState=${this.eventSource?.readyState}, isConnected=${this.isConnected}`
);
}, 100);
setTimeout(() => {
logger.log(
`SSE state after 500ms: readyState=${this.eventSource?.readyState}, isConnected=${this.isConnected}`
);
}, 500);
setTimeout(() => {
logger.log(
`SSE state after 1s: readyState=${this.eventSource?.readyState}, isConnected=${this.isConnected}`
);
// If we're connected but onopen didn't fire, manually set connected state
if (this.eventSource?.readyState === EventSource.OPEN && !this.isConnected) {
logger.warn('⚠️ SSE is OPEN but onopen never fired - manually setting connected state');
this.isConnected = true;
this.isConnecting = false;
this.notifyConnectionState(true);
}
}, 1000);
this.eventSource.onmessage = (event) => {
logger.log('📨 Received SSE message:', event.data);
try {
const data = JSON.parse(event.data);
logger.log('Parsed notification event:', data);
// If we receive the initial "connected" event, mark as connected
if (data.type === 'connected') {
logger.log('✅ Received connected event from SSE');
if (!this.isConnected) {
this.isConnected = true;
this.isConnecting = false;
this.notifyConnectionState(true);
}
}
// Notify listeners for this event type
if (data.type) {
this.notify(data.type, data);
}
} catch (_error) {
logger.log('Received non-JSON event:', event.data);
}
};
this.eventSource.onerror = (error) => {
// EventSource error events don't contain much information
// Check readyState to understand what happened
const readyState = this.eventSource?.readyState;
const currentUrl = this.eventSource?.url || 'unknown';
if (readyState === EventSource.CONNECTING) {
logger.warn(
`⚠️ SSE connection failed while connecting to ${currentUrl} (likely auth or CORS issue)`
);
} else if (readyState === EventSource.OPEN) {
logger.warn('⚠️ SSE connection error while open (network issue)');
} else if (readyState === EventSource.CLOSED) {
logger.debug('SSE connection closed');
}
logger.error('❌ Notification event stream error:', error);
logger.log(
`EventSource readyState on error: ${readyState} (0=CONNECTING, 1=OPEN, 2=CLOSED), URL: ${currentUrl}`
);
this.isConnected = false;
this.isConnecting = false;
this.notifyConnectionState(false);
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
}
/**
* Disconnect from the event stream
*/
disconnect(): void {
logger.log('Disconnecting from notification event stream');
this.shouldReconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
this.isConnected = false;
this.notifyConnectionState(false);
}
}
/**
* Schedule a reconnection attempt with exponential backoff
*/
private scheduleReconnect(): void {
if (this.reconnectTimer || !this.shouldReconnect) {
return;
}
logger.debug(`Scheduling reconnect in ${this.reconnectDelay}ms...`);
// Clean up existing connection
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (this.shouldReconnect) {
this.connect();
// Exponential backoff with max delay
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
}, this.reconnectDelay);
}
/**
* Get the current connection status
*/
getConnectionStatus(): boolean {
return this.isConnected;
}
/**
* Register a handler for connection state changes
*/
onConnectionStateChange(handler: ConnectionStateHandler): () => void {
this.connectionStateHandlers.add(handler);
// Return unsubscribe function
return () => {
this.connectionStateHandlers.delete(handler);
};
}
/**
* Register a handler for a specific event type
*/
on(eventType: string, handler: EventHandler): () => void {
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, new Set());
}
this.eventListeners.get(eventType)?.add(handler);
// Return unsubscribe function
return () => {
this.off(eventType, handler);
};
}
/**
* Unregister a handler for a specific event type
*/
off(eventType: string, handler: EventHandler): void {
this.eventListeners.get(eventType)?.delete(handler);
}
/**
* Notify all handlers of a specific event type
*/
private notify(eventType: string, data: unknown): void {
this.eventListeners.get(eventType)?.forEach((handler) => {
try {
handler(data);
} catch (error) {
logger.error(`Error in event handler for ${eventType}:`, error);
}
});
}
/**
* Notify all handlers of connection state change
*/
private notifyConnectionState(connected: boolean): void {
this.connectionStateHandlers.forEach((handler) => {
try {
handler(connected);
} catch (error) {
logger.error('Error in connection state handler:', error);
}
});
}
/**
* Set the auth client (for when it becomes available later)
*/
setAuthClient(authClient: AuthClient): void {
this.authClient = authClient;
// Don't reconnect if we already have an event source
// The EventSource API doesn't require auth headers in browsers
// as it uses cookies for authentication
}
}
// Create singleton instance
export const notificationEventService = new NotificationEventService();

View file

@ -4,6 +4,7 @@ import type { NotificationPreferences } from '../../types/config.js';
import { DEFAULT_NOTIFICATION_PREFERENCES } from '../../types/config.js';
import { createLogger } from '../utils/logger';
import { authClient } from './auth-client';
import { notificationEventService } from './notification-event-service';
import { serverConfigService } from './server-config-service';
// Re-export types for components
@ -368,57 +369,45 @@ export class PushNotificationService {
async testNotification(): Promise<void> {
logger.log('🔔 Testing notification system...');
try {
// Set up SSE listener for test notifications before sending the request
const eventSource = new EventSource('/api/events');
let receivedNotification = false;
if (!this.serviceWorkerRegistration) {
throw new Error('Service worker not initialized');
}
try {
// Promise that resolves when we receive the test notification
const notificationPromise = new Promise<void>((resolve) => {
let receivedNotification = false;
const timeout = setTimeout(() => {
if (!receivedNotification) {
logger.warn('⏱️ Timeout waiting for SSE test notification');
eventSource.close();
unsubscribe();
resolve();
}
}, 5000); // 5 second timeout
eventSource.addEventListener('test-notification', async (event) => {
try {
const data = JSON.parse(event.data);
logger.log('📨 Received test notification via SSE:', data);
receivedNotification = true;
clearTimeout(timeout);
const unsubscribe = notificationEventService.on('test-notification', async (data: any) => {
logger.log('📨 Received test notification via SSE:', data);
receivedNotification = true;
clearTimeout(timeout);
unsubscribe();
// Show notification if we have permission
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
await this.serviceWorkerRegistration.showNotification(
data.title || 'VibeTunnel Test',
{
body: data.body || 'Test notification received via SSE!',
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-test-sse',
requireInteraction: false,
}
);
logger.log('✅ Displayed SSE test notification');
}
eventSource.close();
resolve();
} catch (error) {
logger.error('Failed to handle SSE test notification:', error);
eventSource.close();
resolve();
// Show notification if we have permission
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
await this.serviceWorkerRegistration.showNotification(
data.title || 'VibeTunnel Test',
{
body: data.body || 'Test notification received via SSE!',
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-test-sse',
requireInteraction: false,
}
);
logger.log('✅ Displayed SSE test notification');
}
});
eventSource.onerror = () => {
logger.error('SSE connection error');
eventSource.close();
resolve();
};
});
});
// Send the test notification request to server
@ -427,13 +416,13 @@ export class PushNotificationService {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authClient.getAuthHeader(),
},
});
if (!response.ok) {
const error = await response.json();
logger.error('❌ Server test notification failed:', error);
eventSource.close();
throw new Error(error.error || 'Failed to send test notification');
}

View file

@ -13,6 +13,7 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
// SSE endpoint for event streaming
router.get('/events', (req: Request, res: Response) => {
logger.info('📡 SSE connection attempt received');
logger.debug('Client connected to event stream');
// Set headers for SSE
@ -20,6 +21,7 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('X-Accel-Buffering', 'no'); // Disable proxy buffering
// Event ID counter
let eventId = 0;
@ -27,7 +29,6 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
let keepAlive: NodeJS.Timeout;
// Forward-declare event handlers for cleanup
// biome-ignore lint/style/useConst: These are assigned later in the code
let onNotification: (event: ServerEvent) => void;
// Cleanup function to remove event listeners
@ -42,7 +43,7 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
// Send initial connection event as default message event
try {
res.write('data: {"type": "connected"}\n\n');
res.write('event: connected\ndata: {"type": "connected"}\n\n');
} catch (error) {
logger.debug('Failed to send initial connection event:', error);
return;
@ -69,16 +70,15 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
logger.info('🧪 Forwarding test notification through SSE:', event);
}
// Send as default message event (not named event) for compatibility with Mac EventSource
// The event type is already included in the data payload
const sseMessage = `id: ${++eventId}\ndata: ${JSON.stringify(event)}\n\n`;
try {
const sseMessage = `id: ${++eventId}\nevent: ${
event.type
}\ndata: ${JSON.stringify(event)}\n\n`;
res.write(sseMessage);
logger.debug(`✅ SSE event written: ${event.type}`);
} catch (error) {
logger.debug('Failed to write SSE event:', error);
cleanup();
logger.error('Failed to write SSE event:', error);
}
};

View file

@ -250,7 +250,7 @@ describe('VibeTunnelSocketClient', () => {
const client = new VibeTunnelSocketClient(socketPath);
const statusHandler = vi.fn();
client.on('status', statusHandler);
client.on('STATUS_UPDATE', statusHandler);
await client.connect();
// Send status from server
@ -274,7 +274,7 @@ describe('VibeTunnelSocketClient', () => {
const client = new VibeTunnelSocketClient(socketPath);
const errorHandler = vi.fn();
client.on('serverError', errorHandler);
client.on('ERROR', errorHandler);
await client.connect();
// Send error from server
@ -320,7 +320,7 @@ describe('VibeTunnelSocketClient', () => {
const client = new VibeTunnelSocketClient(socketPath);
const statusHandler = vi.fn();
client.on('status', statusHandler);
client.on('STATUS_UPDATE', statusHandler);
await client.connect();
// Send multiple messages at once
@ -343,7 +343,7 @@ describe('VibeTunnelSocketClient', () => {
const client = new VibeTunnelSocketClient(socketPath);
const statusHandler = vi.fn();
client.on('status', statusHandler);
client.on('STATUS_UPDATE', statusHandler);
await client.connect();
// Create a message and split it