diff --git a/mac/VibeTunnel/Core/Services/BunServer.swift b/mac/VibeTunnel/Core/Services/BunServer.swift index 537e282e..937d3a8b 100644 --- a/mac/VibeTunnel/Core/Services/BunServer.swift +++ b/mac/VibeTunnel/Core/Services/BunServer.swift @@ -929,11 +929,18 @@ final class BunServer { } private func monitorProcessTermination() async { - guard let process else { return } + // Capture process reference to avoid race conditions + guard let process = self.process else { return } // Wait for process exit await process.waitUntilExitAsync() + // Check if process is still valid before accessing terminationStatus + guard self.process != nil else { + logger.warning("Process was deallocated during termination monitoring") + return + } + let exitCode = process.terminationStatus // Check current state diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index f6885313..b2c7cbb8 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -10,14 +10,16 @@ import os.log /// command completions, and errors, then displays them as native macOS notifications. @MainActor @Observable -final class NotificationService: NSObject { +final class NotificationService: NSObject, @preconcurrency UNUserNotificationCenterDelegate { @MainActor - static let shared = NotificationService() + static let shared: NotificationService = { + // Defer initialization to avoid circular dependency + // This ensures ServerManager and ConfigManager are ready + return NotificationService() + }() private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "NotificationService") private var eventSource: EventSource? - private let serverManager = ServerManager.shared - private let configManager = ConfigManager.shared private var isConnected = false private var recentlyNotifiedSessions = Set() private var notificationCleanupTimer: Timer? @@ -36,6 +38,27 @@ final class NotificationService: NSObject { var soundEnabled: Bool var vibrationEnabled: Bool + // Memberwise initializer + init( + sessionStart: Bool, + sessionExit: Bool, + commandCompletion: Bool, + commandError: Bool, + bell: Bool, + claudeTurn: Bool, + soundEnabled: Bool, + vibrationEnabled: Bool + ) { + self.sessionStart = sessionStart + self.sessionExit = sessionExit + self.commandCompletion = commandCompletion + self.commandError = commandError + self.bell = bell + self.claudeTurn = claudeTurn + self.soundEnabled = soundEnabled + self.vibrationEnabled = vibrationEnabled + } + @MainActor init(fromConfig configManager: ConfigManager) { // Load from ConfigManager - ConfigManager provides the defaults @@ -52,34 +75,62 @@ final class NotificationService: NSObject { private var preferences: NotificationPreferences + // Dependencies (will be set after init to avoid circular dependency) + private weak var serverProvider: ServerManager? + private weak var configProvider: ConfigManager? + @MainActor override private init() { - // Load preferences from ConfigManager - self.preferences = NotificationPreferences(fromConfig: configManager) - + // Initialize with default preferences first + self.preferences = NotificationPreferences( + sessionStart: true, + sessionExit: true, + commandCompletion: true, + commandError: true, + bell: true, + claudeTurn: true, + soundEnabled: true, + vibrationEnabled: true + ) + super.init() - setupNotifications() - - // Listen for config changes - listenForConfigChanges() + + // Defer dependency setup to avoid circular initialization + Task { @MainActor in + self.serverProvider = ServerManager.shared + self.configProvider = ConfigManager.shared + // Now load actual preferences + if let configProvider = self.configProvider { + self.preferences = NotificationPreferences(fromConfig: configProvider) + } + setupNotifications() + listenForConfigChanges() + } } /// Start monitoring server events func start() async { logger.info("๐Ÿš€ NotificationService.start() called") - + + // Set delegate here to ensure it's done at the right time + UNUserNotificationCenter.current().delegate = self + logger.info("โœ… NotificationService set as UNUserNotificationCenter delegate in start()") + + // Debug: Log current delegate to verify it's set + let currentDelegate = UNUserNotificationCenter.current().delegate + logger.info("๐Ÿ” Current UNUserNotificationCenter delegate: \(String(describing: currentDelegate))") // Check if notifications are enabled in config - guard configManager.notificationsEnabled else { + guard let configProvider = configProvider, configProvider.notificationsEnabled else { logger.info("๐Ÿ“ด Notifications are disabled in config, skipping SSE connection") return } - - guard serverManager.isRunning else { + + guard let serverProvider = serverProvider, serverProvider.isRunning else { logger.warning("๐Ÿ”ด Server not running, cannot start notification service") return } - logger.info("๐Ÿ”” Starting notification service - server is running on port \(self.serverManager.port)") + logger.info("๐Ÿ”” Starting notification service - server is running on port \(serverProvider.port)") // Wait for Unix socket to be ready before connecting SSE // This ensures the server is fully ready to accept connections @@ -127,6 +178,10 @@ final class NotificationService: NSObject { /// Request notification permissions and show test notification func requestPermissionAndShowTestNotification() async -> Bool { let center = UNUserNotificationCenter.current() + + // Debug: Log current notification settings + let settings = await center.notificationSettings() + logger.info("๐Ÿ”” Current notification settings - authorizationStatus: \(settings.authorizationStatus.rawValue, privacy: .public), alertSetting: \(settings.alertSetting.rawValue, privacy: .public)") switch await authorizationStatus() { case .notDetermined: @@ -136,6 +191,10 @@ final class NotificationService: NSObject { if granted { logger.info("โœ… Notification permissions granted") + + // Debug: Log granted settings + let newSettings = await center.notificationSettings() + logger.info("๐Ÿ”” New settings after grant - alert: \(newSettings.alertSetting.rawValue, privacy: .public), sound: \(newSettings.soundSetting.rawValue, privacy: .public), badge: \(newSettings.badgeSetting.rawValue, privacy: .public)") // Show test notification let content = UNMutableNotificationContent() @@ -188,7 +247,7 @@ final class NotificationService: NSObject { /// - Parameter event: The server event to create a notification for func sendNotification(for event: ServerEvent) async { // Check master switch first - guard configManager.notificationsEnabled else { return } + guard configProvider?.notificationsEnabled ?? false else { return } // Check preferences based on event type switch event.type { @@ -284,7 +343,7 @@ final class NotificationService: NSObject { /// Send a session start notification (legacy method for compatibility) func sendSessionStartNotification(sessionName: String) async { - guard configManager.notificationsEnabled && preferences.sessionStart else { return } + guard configProvider?.notificationsEnabled ?? false && preferences.sessionStart else { return } let content = UNMutableNotificationContent() content.title = "Session Started" @@ -298,7 +357,7 @@ final class NotificationService: NSObject { /// Send a session exit notification (legacy method for compatibility) func sendSessionExitNotification(sessionName: String, exitCode: Int) async { - guard configManager.notificationsEnabled && preferences.sessionExit else { return } + guard configProvider?.notificationsEnabled ?? false && preferences.sessionExit else { return } let content = UNMutableNotificationContent() content.title = "Session Ended" @@ -315,7 +374,7 @@ final class NotificationService: NSObject { /// Send a command completion notification (legacy method for compatibility) func sendCommandCompletionNotification(command: String, duration: Int) async { - guard configManager.notificationsEnabled && preferences.commandCompletion else { return } + guard configProvider?.notificationsEnabled ?? false && preferences.commandCompletion else { return } let content = UNMutableNotificationContent() content.title = "Your Turn" @@ -341,7 +400,7 @@ final class NotificationService: NSObject { /// Send a generic notification func sendGenericNotification(title: String, body: String) async { - guard configManager.notificationsEnabled else { return } + guard configProvider?.notificationsEnabled ?? false else { return } let content = UNMutableNotificationContent() content.title = title @@ -352,6 +411,30 @@ final class NotificationService: NSObject { deliverNotification(content, identifier: "generic-\(UUID().uuidString)") } + /// Send a test notification for debugging and verification + func sendTestNotification(title: String? = nil, message: String? = nil, sessionId: String? = nil) async { + guard configProvider?.notificationsEnabled ?? false else { return } + + let content = UNMutableNotificationContent() + content.title = title ?? "Test Notification" + content.body = message ?? "This is a test notification from VibeTunnel" + content.sound = getNotificationSound() + content.categoryIdentifier = "TEST" + content.interruptionLevel = .passive + + if let sessionId = sessionId { + content.subtitle = "Session: \(sessionId)" + content.userInfo = ["sessionId": sessionId, "type": "test-notification"] + } else { + content.userInfo = ["type": "test-notification"] + } + + let identifier = "test-\(sessionId ?? UUID().uuidString)" + deliverNotification(content, identifier: identifier) + + logger.info("๐Ÿงช Test notification sent: \(title ?? "Test Notification") - \(message ?? "Test message")") + } + /// Open System Settings to the Notifications pane func openNotificationSettings() { if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") { @@ -364,7 +447,7 @@ final class NotificationService: NSObject { self.preferences = prefs // Update ConfigManager - configManager.updateNotificationPreferences( + configProvider?.updateNotificationPreferences( sessionStart: prefs.sessionStart, sessionExit: prefs.sessionExit, commandCompletion: prefs.commandCompletion, @@ -424,7 +507,8 @@ final class NotificationService: NSObject { } private func connect() { - logger.info("๐Ÿ”Œ NotificationService.connect() called - isConnected: \(self.isConnected)") + // Using interpolation to bypass privacy restrictions for debugging + logger.info("๐Ÿ”Œ NotificationService.connect() called - isConnected: \(self.isConnected, privacy: .public)") guard !isConnected else { logger.info("Already connected to notification service") return @@ -432,15 +516,19 @@ final class NotificationService: NSObject { // When auth mode is "none", we can connect without a token. // In any other auth mode, a token is required for the local Mac app to connect. - if serverManager.authMode != "none", serverManager.localAuthToken == nil { - logger - .error("No auth token available for notification service in auth mode '\(self.serverManager.authMode)'") + guard let serverProvider = self.serverProvider else { + logger.error("Server provider is not available") + return + } + + if serverProvider.authMode != "none", serverProvider.localAuthToken == nil { + logger.error("No auth token available for notification service in auth mode '\(serverProvider.authMode)'") return } - let eventsURL = "http://localhost:\(self.serverManager.port)/api/events" - logger.info("๐Ÿ“ก Attempting to connect to SSE endpoint: \(eventsURL)") - + let eventsURL = "http://localhost:\(serverProvider.port)/api/events" + // Show full URL for debugging SSE connection issues + logger.info("๐Ÿ“ก Attempting to connect to SSE endpoint: \(eventsURL, privacy: .public)") guard let url = URL(string: eventsURL) else { logger.error("Invalid events URL: \(eventsURL)") return @@ -454,11 +542,13 @@ final class NotificationService: NSObject { // Add authorization header if auth token is available. // When auth mode is "none", there's no token, and that's okay. - if let authToken = serverManager.localAuthToken { + if let authToken = serverProvider.localAuthToken { headers["Authorization"] = "Bearer \(authToken)" - logger.info("๐Ÿ”‘ Using auth token for SSE connection") + // Show token prefix for debugging (first 10 chars only for security) + let tokenPrefix = String(authToken.prefix(10)) + logger.info("๐Ÿ”‘ Using auth token for SSE connection: \(tokenPrefix, privacy: .public)...") } else { - logger.info("๐Ÿ”“ Connecting to SSE without an auth token (auth mode: '\(self.serverManager.authMode)')") + logger.info("๐Ÿ”“ Connecting to SSE without an auth token (auth mode: '\(serverProvider.authMode)')") } // Add custom header to indicate this is the Mac app @@ -468,8 +558,9 @@ final class NotificationService: NSObject { eventSource?.onOpen = { [weak self] in Task { @MainActor in - self?.logger.info("โœ… Connected to notification event stream") - self?.isConnected = true + guard let self else { return } + self.logger.info("โœ… Connected to notification event stream") + self.isConnected = true // Post notification for UI update NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) } @@ -477,10 +568,11 @@ final class NotificationService: NSObject { eventSource?.onError = { [weak self] error in Task { @MainActor in + guard let self else { return } if let error { - self?.logger.error("โŒ EventSource error: \(error)") + self.logger.error("โŒ EventSource error: \(error)") } - self?.isConnected = false + self.isConnected = false // Post notification for UI update NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) // Don't reconnect here - let server state changes trigger reconnection @@ -489,11 +581,9 @@ final class NotificationService: NSObject { eventSource?.onMessage = { [weak self] event in Task { @MainActor in - self?.logger - .info( - "๐ŸŽฏ EventSource onMessage fired! Event type: \(event.event ?? "default"), Has data: \(event.data != nil)" - ) - self?.handleEvent(event) + guard let self else { return } + self.logger.info("๐ŸŽฏ EventSource onMessage fired! Event type: \(event.event ?? "default", privacy: .public), Has data: \(event.data != nil, privacy: .public)") + await self.handleEvent(event) } } @@ -509,7 +599,7 @@ final class NotificationService: NSObject { NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) } - private func handleEvent(_ event: Event) { + private func handleEvent(_ event: Event) async { guard let data = event.data else { logger.warning("Received event with no data") return @@ -536,42 +626,42 @@ final class NotificationService: NSObject { switch type { case "session-start": logger.info("๐Ÿš€ Processing session-start event") - if configManager.notificationsEnabled && preferences.sessionStart { + if configProvider?.notificationsEnabled ?? false && preferences.sessionStart { handleSessionStart(json) } else { logger.debug("Session start notifications disabled") } case "session-exit": logger.info("๐Ÿ Processing session-exit event") - if configManager.notificationsEnabled && preferences.sessionExit { + if configProvider?.notificationsEnabled ?? false && preferences.sessionExit { handleSessionExit(json) } else { logger.debug("Session exit notifications disabled") } case "command-finished": logger.info("โœ… Processing command-finished event") - if configManager.notificationsEnabled && preferences.commandCompletion { + if configProvider?.notificationsEnabled ?? false && preferences.commandCompletion { handleCommandFinished(json) } else { logger.debug("Command completion notifications disabled") } case "command-error": logger.info("โŒ Processing command-error event") - if configManager.notificationsEnabled && preferences.commandError { + if configProvider?.notificationsEnabled ?? false && preferences.commandError { handleCommandError(json) } else { logger.debug("Command error notifications disabled") } case "bell": logger.info("๐Ÿ”” Processing bell event") - if configManager.notificationsEnabled && preferences.bell { + if configProvider?.notificationsEnabled ?? false && preferences.bell { handleBell(json) } else { logger.debug("Bell notifications disabled") } case "claude-turn": logger.info("๐Ÿ’ฌ Processing claude-turn event") - if configManager.notificationsEnabled && preferences.claudeTurn { + if configProvider?.notificationsEnabled ?? false && preferences.claudeTurn { handleClaudeTurn(json) } else { logger.debug("Claude turn notifications disabled") @@ -586,7 +676,7 @@ final class NotificationService: NSObject { logger.warning("Unknown event type: \(type)") } } catch { - logger.error("Failed to parse event data: \(error)") + logger.error("Failed to parse legacy event data: \(error)") } } @@ -727,8 +817,8 @@ final class NotificationService: NSObject { } private func handleTestNotification(_ json: [String: Any]) { - logger.info("๐Ÿงช Handling test notification from server") - + // Debug: Show full test notification data + logger.info("๐Ÿงช Handling test notification from server - JSON: \(json, privacy: .public)") let title = json["title"] as? String ?? "VibeTunnel Test" let body = json["body"] as? String ?? "Server-side notifications are working correctly!" let message = json["message"] as? String @@ -773,11 +863,12 @@ final class NotificationService: NSObject { private func deliverNotification(_ content: UNNotificationContent, identifier: String) { let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) - UNUserNotificationCenter.current().add(request) { [weak self] error in - if let error { - self?.logger.error("Failed to deliver notification: \(error)") - } else { - self?.logger.debug("Notification delivered: \(identifier)") + Task { @MainActor in + do { + try await UNUserNotificationCenter.current().add(request) + self.logger.debug("Notification delivered: \(identifier, privacy: .public)") + } catch { + self.logger.error("Failed to deliver notification: \(error, privacy: .public) for identifier: \(identifier, privacy: .public)") } } } @@ -790,7 +881,8 @@ final class NotificationService: NSObject { deliverNotification(content, identifier: identifier) // Schedule automatic dismissal - DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) } } @@ -798,17 +890,21 @@ final class NotificationService: NSObject { // MARK: - Cleanup private func scheduleNotificationCleanup(for key: String, after seconds: TimeInterval) { - DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in - self?.recentlyNotifiedSessions.remove(key) + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + self.recentlyNotifiedSessions.remove(key) } } /// Send a test notification through the server to verify the full flow + @MainActor func sendServerTestNotification() async { logger.info("๐Ÿงช Sending test notification through server...") - + // Show thread details for debugging dispatch issues + logger.info("๐Ÿงต Current thread: \(Thread.current, privacy: .public)") + logger.info("๐Ÿงต Is main thread: \(Thread.isMainThread, privacy: .public)") // Check if server is running - guard serverManager.isRunning else { + guard serverProvider?.isRunning ?? false else { logger.error("โŒ Cannot send test notification - server is not running") return } @@ -816,32 +912,27 @@ final class NotificationService: NSObject { // If not connected to SSE, try to connect first if !isConnected { logger.warning("โš ๏ธ Not connected to SSE endpoint, attempting to connect...") - await MainActor.run { - connect() - } + connect() // Give it a moment to connect try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds } // Log server info - logger - .info( - "Server info - Port: \(self.serverManager.port), Running: \(self.serverManager.isRunning), SSE Connected: \(self.isConnected)" - ) - - guard let url = serverManager.buildURL(endpoint: "/api/test-notification") else { + logger.info("Server info - Port: \(self.serverProvider?.port ?? "unknown"), Running: \(self.serverProvider?.isRunning ?? false), SSE Connected: \(self.isConnected)") + + guard let url = serverProvider?.buildURL(endpoint: "/api/test-notification") else { logger.error("โŒ Failed to build test notification URL") return } - - logger.info("๐Ÿ“ค Sending POST request to: \(url)") - + + // Show full URL for debugging test notification endpoint + logger.info("๐Ÿ“ค Sending POST request to: \(url, privacy: .public)") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") // Add auth token if available - if let authToken = serverManager.localAuthToken { + if let authToken = serverProvider?.localAuthToken { request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") logger.debug("Added auth token to request") } @@ -850,17 +941,19 @@ final class NotificationService: NSObject { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { - logger.info("๐Ÿ“ฅ Received response - Status: \(httpResponse.statusCode)") - + // Show HTTP status code for debugging + logger.info("๐Ÿ“ฅ Received response - Status: \(httpResponse.statusCode, privacy: .public)") if httpResponse.statusCode == 200 { logger.info("โœ… Server test notification sent successfully") if let responseData = String(data: data, encoding: .utf8) { - logger.debug("Response data: \(responseData)") + // Show full response for debugging + logger.debug("Response data: \(responseData, privacy: .public)") } } else { logger.error("โŒ Server test notification failed with status: \(httpResponse.statusCode)") if let errorData = String(data: data, encoding: .utf8) { - logger.error("Error response: \(errorData)") + // Show full error response for debugging + logger.error("Error response: \(errorData, privacy: .public)") } } } @@ -875,4 +968,28 @@ final class NotificationService: NSObject { // The cleanup will happen when the EventSource is deallocated // NotificationCenter observers are automatically removed on deinit in modern Swift } + + // MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Debug: Show full notification details + logger.info("๐Ÿ”” willPresent notification - identifier: \(notification.request.identifier, privacy: .public), title: \(notification.request.content.title, privacy: .public), body: \(notification.request.content.body, privacy: .public)") + // Show notifications even when app is in foreground + completionHandler([.banner, .sound, .list]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Debug: Show interaction details + logger.info("๐Ÿ”” didReceive response - identifier: \(response.notification.request.identifier, privacy: .public), actionIdentifier: \(response.actionIdentifier, privacy: .public)") + // Handle notification actions here if needed in the future + completionHandler() + } } diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index 7330f158..34ffbd6c 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -176,6 +176,8 @@ class ServerManager { // Ensure our state is synced isRunning = true lastError = nil + // Start notification service if server is already running + await NotificationService.shared.start() return case .starting: logger.info("Server is already starting") diff --git a/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift index 64a8fb88..64db05f1 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/NotificationSettingsView.swift @@ -182,12 +182,12 @@ struct NotificationSettingsView: View { VStack(alignment: .leading, spacing: 12) { HStack { Button("Test Notification") { - Task { + Task { @MainActor in isTestingNotification = true // Use server test notification to verify the full flow await notificationService.sendServerTestNotification() // Reset button state after a delay - try? await Task.sleep(nanoseconds: 1_000_000_000) + await Task.yield() isTestingNotification = false } } diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index e630503b..cd3f014c 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -143,7 +143,7 @@ struct VibeTunnelApp: App { /// URL scheme handling, and user notification management. Acts as the central /// coordinator for application-wide events and services. @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate { // Needed for menu item highlight hack weak static var shared: AppDelegate? override init() { @@ -205,9 +205,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Initialize Sparkle updater manager sparkleUpdaterManager = SparkleUpdaterManager.shared - // Set up notification center delegate - UNUserNotificationCenter.current().delegate = self - // Initialize dock icon visibility through DockIconManager DockIconManager.shared.updateDockVisibility() @@ -484,36 +481,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser logger.info("๐Ÿšจ applicationWillTerminate completed quickly") } - // MARK: - UNUserNotificationCenterDelegate - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - logger.info("Received notification response: \(response.actionIdentifier)") - - // Handle update reminder actions - if response.notification.request.content.categoryIdentifier == "UPDATE_REMINDER" { - sparkleUpdaterManager?.userDriverDelegate?.handleNotificationAction( - response.actionIdentifier, - userInfo: response.notification.request.content.userInfo - ) - } - - completionHandler() - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) - -> Void - ) { - // Show notifications even when app is in foreground - completionHandler([.banner, .sound]) - } - /// Set up lightweight cleanup system for cloudflared processes private func setupMultiLayerCleanup() { logger.info("๐Ÿ›ก๏ธ Setting up cloudflared cleanup system") diff --git a/web/src/client/app.ts b/web/src/client/app.ts index 0cc58adf..a6e9aa85 100644 --- a/web/src/client/app.ts +++ b/web/src/client/app.ts @@ -527,8 +527,19 @@ export class VibeTunnelApp extends LitElement { // Initialize push notification service always // It handles its own permission checks and user preferences + logger.log('Initializing push notification service...'); await pushNotificationService.initialize(); + // Log the initialization status + const isSupported = pushNotificationService.isSupported(); + const isSecure = window.isSecureContext; + logger.log('Push notification initialization complete:', { + isSupported, + isSecureContext: isSecure, + location: window.location.hostname, + protocol: window.location.protocol, + }); + // Initialize control event service for real-time notifications this.controlEventService = getControlEventService(authClient); this.controlEventService.connect(); diff --git a/web/src/client/components/session-view.ts b/web/src/client/components/session-view.ts index 10fca5c4..57240201 100644 --- a/web/src/client/components/session-view.ts +++ b/web/src/client/components/session-view.ts @@ -900,7 +900,7 @@ export class SessionView extends LitElement { if ('scrollToBottom' in terminal) { terminal.scrollToBottom(); } - + // Also ensure the terminal content is scrolled within its container const terminalArea = this.querySelector('.terminal-area'); if (terminalArea) { diff --git a/web/src/client/components/session-view/lifecycle-event-manager.ts b/web/src/client/components/session-view/lifecycle-event-manager.ts index 46c5bf60..0f1b4c8a 100644 --- a/web/src/client/components/session-view/lifecycle-event-manager.ts +++ b/web/src/client/components/session-view/lifecycle-event-manager.ts @@ -30,7 +30,6 @@ const logger = createLogger('lifecycle-event-manager'); export type { LifecycleEventManagerCallbacks } from './interfaces.js'; export class LifecycleEventManager extends ManagerEventEmitter { - private sessionViewElement: HTMLElement | null = null; private callbacks: LifecycleEventManagerCallbacks | null = null; private session: Session | null = null; private touchStartX = 0; diff --git a/web/src/client/components/settings.ts b/web/src/client/components/settings.ts index 6b080c16..e6bf39ff 100644 --- a/web/src/client/components/settings.ts +++ b/web/src/client/components/settings.ts @@ -101,6 +101,8 @@ export class Settings extends LitElement { this.requestUpdate(); // Discover repositories when settings are opened this.discoverRepositories(); + // Refresh notification state when dialog opens + this.refreshNotificationState(); } else { document.removeEventListener('keydown', this.handleKeyDown); } @@ -129,18 +131,46 @@ export class Settings extends LitElement { this.subscription = pushNotificationService.getSubscription(); this.notificationPreferences = await pushNotificationService.loadPreferences(); + // Get detailed subscription status for debugging + const status = pushNotificationService.getSubscriptionStatus(); + logger.debug('Notification initialization status:', status); + + // If notifications are enabled but no subscription, try to force refresh + if (this.notificationPreferences.enabled && !this.subscription && status.hasPermission) { + logger.log('Notifications enabled but no subscription found, attempting to refresh...'); + await pushNotificationService.forceRefreshSubscription(); + + // Update state after refresh + this.subscription = pushNotificationService.getSubscription(); + } + // Listen for changes this.permissionChangeUnsubscribe = pushNotificationService.onPermissionChange((permission) => { this.permission = permission; + this.requestUpdate(); }); this.subscriptionChangeUnsubscribe = pushNotificationService.onSubscriptionChange( (subscription) => { this.subscription = subscription; + this.requestUpdate(); } ); } + private async refreshNotificationState(): Promise { + // Refresh current state from the push notification service + this.permission = pushNotificationService.getPermission(); + this.subscription = pushNotificationService.getSubscription(); + this.notificationPreferences = await pushNotificationService.loadPreferences(); + + logger.debug('Refreshed notification state:', { + permission: this.permission, + hasSubscription: !!this.subscription, + preferencesEnabled: this.notificationPreferences.enabled, + }); + } + updated(changedProperties: PropertyValues) { super.updated(changedProperties); @@ -247,10 +277,24 @@ export class Settings extends LitElement { // Enable notifications const permission = await pushNotificationService.requestPermission(); if (permission === 'granted') { + // Check if this is the first time enabling notifications + const currentPrefs = await pushNotificationService.loadPreferences(); + if (!currentPrefs.enabled) { + // First time enabling - use recommended defaults + this.notificationPreferences = pushNotificationService.getRecommendedPreferences(); + logger.log('Using recommended notification preferences for first-time enable'); + } else { + // Already enabled before - just toggle the enabled state + this.notificationPreferences = { ...this.notificationPreferences, enabled: true }; + } + const subscription = await pushNotificationService.subscribe(); if (subscription) { - this.notificationPreferences = { ...this.notificationPreferences, enabled: true }; await pushNotificationService.savePreferences(this.notificationPreferences); + + // Show welcome notification + await this.showWelcomeNotification(); + this.dispatchEvent(new CustomEvent('notifications-enabled')); } else { this.dispatchEvent( @@ -263,27 +307,106 @@ export class Settings extends LitElement { this.dispatchEvent( new CustomEvent('error', { detail: - permission === 'denied' - ? 'Notifications permission denied' - : 'Notifications permission not granted', + 'Notification permission denied. Please enable notifications in your browser settings.', }) ); } } + } catch (error) { + logger.error('Failed to toggle notifications:', error); + this.dispatchEvent( + new CustomEvent('error', { + detail: 'Failed to toggle notifications', + }) + ); } finally { this.isLoading = false; } } + private async handleForceRefresh() { + try { + await pushNotificationService.forceRefreshSubscription(); + + // Update state after refresh + this.subscription = pushNotificationService.getSubscription(); + this.notificationPreferences = await pushNotificationService.loadPreferences(); + + logger.log('Force refresh completed'); + } catch (error) { + logger.error('Force refresh failed:', error); + } + } + private async handleTestNotification() { if (this.testingNotification) return; this.testingNotification = true; try { - await pushNotificationService.testNotification(); + logger.log('๐Ÿงช Starting test notification...'); + + // Step 1: Check service worker + logger.debug('Step 1: Checking service worker registration'); + if (!pushNotificationService.isSupported()) { + throw new Error('Push notifications not supported in this browser'); + } + + // Step 2: Check permissions + logger.debug('Step 2: Checking notification permissions'); + const permission = pushNotificationService.getPermission(); + if (permission !== 'granted') { + throw new Error(`Notification permission is ${permission}, not granted`); + } + + // Step 3: Check subscription + logger.debug('Step 3: Checking push subscription'); + const subscription = pushNotificationService.getSubscription(); + if (!subscription) { + throw new Error('No active push subscription found'); + } + + // Step 4: Check server status + logger.debug('Step 4: Checking server push notification status'); + const serverStatus = await pushNotificationService.getServerStatus(); + if (!serverStatus.enabled) { + throw new Error('Push notifications disabled on server'); + } + + if (!serverStatus.configured) { + throw new Error('VAPID keys not configured on server'); + } + + // Step 5: Send test notification + logger.debug('Step 5: Sending test notification'); + await pushNotificationService.sendTestNotification('Test notification from VibeTunnel'); + + logger.log('โœ… Test notification sent successfully'); this.dispatchEvent( new CustomEvent('success', { - detail: 'Test notification sent', + detail: 'Test notification sent successfully', + }) + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('โŒ Test notification failed:', errorMessage); + + // Provide specific guidance based on error + let guidance = ''; + if (errorMessage.includes('permission')) { + guidance = 'Please grant notification permissions in your browser settings'; + } else if (errorMessage.includes('subscription')) { + guidance = 'Please enable notifications in settings first'; + } else if (errorMessage.includes('server')) { + guidance = 'Server push notification service is not available'; + } else if (errorMessage.includes('VAPID')) { + guidance = 'VAPID keys are not properly configured'; + } else { + guidance = 'Check browser console for more details'; + } + + this.dispatchEvent( + new CustomEvent('error', { + detail: `Test notification failed: ${errorMessage}. ${guidance}`, }) ); } finally { @@ -299,6 +422,29 @@ export class Settings extends LitElement { await pushNotificationService.savePreferences(this.notificationPreferences); } + private async showWelcomeNotification(): Promise { + // Check if we have a service worker registration + const registration = await navigator.serviceWorker.ready; + if (!registration) { + return; + } + + try { + // Show notification directly + await registration.showNotification('VibeTunnel Notifications Enabled', { + body: "You'll now receive notifications for session events", + icon: '/apple-touch-icon.png', + badge: '/favicon-32.png', + tag: 'vibetunnel-settings-welcome', + requireInteraction: false, + silent: false, + }); + logger.log('Settings welcome notification displayed'); + } catch (error) { + logger.error('Failed to show settings welcome notification:', error); + } + } + private handleAppPreferenceChange(key: keyof AppPreferences, value: boolean | string) { // Update locally this.appPreferences = { ...this.appPreferences, [key]: value }; @@ -442,7 +588,22 @@ export class Settings extends LitElement { Tap the share button in Safari and select "Add to Home Screen" to enable push notifications.

` - : html` + : !window.isSecureContext + ? html` +

+ โš ๏ธ Push notifications require a secure connection +

+

+ You're accessing VibeTunnel via ${window.location.protocol}//${window.location.hostname} +

+

+ To enable notifications, access VibeTunnel using: +
โ€ข https://${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''} +
โ€ข http://localhost:${window.location.port || '4020'} +
โ€ข http://127.0.0.1:${window.location.port || '4020'} +

+ ` + : html`

Push notifications are not supported in this browser.

@@ -509,12 +670,33 @@ export class Settings extends LitElement { + + + ${ + process.env.NODE_ENV === 'development' + ? html` +
+

Debug Information

+
+
Permission: ${this.permission}
+
Subscription: ${this.subscription ? 'Active' : 'None'}
+
Preferences: ${this.notificationPreferences.enabled ? 'Enabled' : 'Disabled'}
+ +
+
+ ` + : '' + } ` : '' } diff --git a/web/src/client/components/terminal-quick-keys.ts b/web/src/client/components/terminal-quick-keys.ts index cfbd0073..e3e597a7 100644 --- a/web/src/client/components/terminal-quick-keys.ts +++ b/web/src/client/components/terminal-quick-keys.ts @@ -628,11 +628,11 @@ export class TerminalQuickKeys extends LitElement {
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map( - ({ key, label, modifier, combo, special, toggle }) => html` + ({ key, label, modifier, combo, toggle }) => html`