Fix Test Notification Button to VibeTunnel Mac App (#483)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alex A. Fallah 2025-07-31 08:57:17 -04:00 committed by GitHub
parent 32935878d8
commit 4523a21f6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 960 additions and 216 deletions

View file

@ -929,11 +929,18 @@ final class BunServer {
} }
private func monitorProcessTermination() async { 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 // Wait for process exit
await process.waitUntilExitAsync() 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 let exitCode = process.terminationStatus
// Check current state // Check current state

View file

@ -10,14 +10,16 @@ import os.log
/// command completions, and errors, then displays them as native macOS notifications. /// command completions, and errors, then displays them as native macOS notifications.
@MainActor @MainActor
@Observable @Observable
final class NotificationService: NSObject { final class NotificationService: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
@MainActor @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 let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "NotificationService")
private var eventSource: EventSource? private var eventSource: EventSource?
private let serverManager = ServerManager.shared
private let configManager = ConfigManager.shared
private var isConnected = false private var isConnected = false
private var recentlyNotifiedSessions = Set<String>() private var recentlyNotifiedSessions = Set<String>()
private var notificationCleanupTimer: Timer? private var notificationCleanupTimer: Timer?
@ -36,6 +38,27 @@ final class NotificationService: NSObject {
var soundEnabled: Bool var soundEnabled: Bool
var vibrationEnabled: 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 @MainActor
init(fromConfig configManager: ConfigManager) { init(fromConfig configManager: ConfigManager) {
// Load from ConfigManager - ConfigManager provides the defaults // Load from ConfigManager - ConfigManager provides the defaults
@ -52,34 +75,62 @@ final class NotificationService: NSObject {
private var preferences: NotificationPreferences 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 @MainActor
override private init() { override private init() {
// Load preferences from ConfigManager // Initialize with default preferences first
self.preferences = NotificationPreferences(fromConfig: configManager) self.preferences = NotificationPreferences(
sessionStart: true,
sessionExit: true,
commandCompletion: true,
commandError: true,
bell: true,
claudeTurn: true,
soundEnabled: true,
vibrationEnabled: true
)
super.init() super.init()
setupNotifications()
// Listen for config changes // Defer dependency setup to avoid circular initialization
listenForConfigChanges() 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 /// Start monitoring server events
func start() async { func start() async {
logger.info("🚀 NotificationService.start() called") 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 // 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") logger.info("📴 Notifications are disabled in config, skipping SSE connection")
return return
} }
guard serverManager.isRunning else { guard let serverProvider = serverProvider, serverProvider.isRunning else {
logger.warning("🔴 Server not running, cannot start notification service") logger.warning("🔴 Server not running, cannot start notification service")
return 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 // Wait for Unix socket to be ready before connecting SSE
// This ensures the server is fully ready to accept connections // This ensures the server is fully ready to accept connections
@ -128,6 +179,10 @@ final class NotificationService: NSObject {
func requestPermissionAndShowTestNotification() async -> Bool { func requestPermissionAndShowTestNotification() async -> Bool {
let center = UNUserNotificationCenter.current() 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() { switch await authorizationStatus() {
case .notDetermined: case .notDetermined:
// First time - request permission // First time - request permission
@ -137,6 +192,10 @@ final class NotificationService: NSObject {
if granted { if granted {
logger.info("✅ Notification permissions 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 // Show test notification
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "VibeTunnel Notifications" content.title = "VibeTunnel Notifications"
@ -188,7 +247,7 @@ final class NotificationService: NSObject {
/// - Parameter event: The server event to create a notification for /// - Parameter event: The server event to create a notification for
func sendNotification(for event: ServerEvent) async { func sendNotification(for event: ServerEvent) async {
// Check master switch first // Check master switch first
guard configManager.notificationsEnabled else { return } guard configProvider?.notificationsEnabled ?? false else { return }
// Check preferences based on event type // Check preferences based on event type
switch event.type { switch event.type {
@ -284,7 +343,7 @@ final class NotificationService: NSObject {
/// Send a session start notification (legacy method for compatibility) /// Send a session start notification (legacy method for compatibility)
func sendSessionStartNotification(sessionName: String) async { func sendSessionStartNotification(sessionName: String) async {
guard configManager.notificationsEnabled && preferences.sessionStart else { return } guard configProvider?.notificationsEnabled ?? false && preferences.sessionStart else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Session Started" content.title = "Session Started"
@ -298,7 +357,7 @@ final class NotificationService: NSObject {
/// Send a session exit notification (legacy method for compatibility) /// Send a session exit notification (legacy method for compatibility)
func sendSessionExitNotification(sessionName: String, exitCode: Int) async { 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() let content = UNMutableNotificationContent()
content.title = "Session Ended" content.title = "Session Ended"
@ -315,7 +374,7 @@ final class NotificationService: NSObject {
/// Send a command completion notification (legacy method for compatibility) /// Send a command completion notification (legacy method for compatibility)
func sendCommandCompletionNotification(command: String, duration: Int) async { 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() let content = UNMutableNotificationContent()
content.title = "Your Turn" content.title = "Your Turn"
@ -341,7 +400,7 @@ final class NotificationService: NSObject {
/// Send a generic notification /// Send a generic notification
func sendGenericNotification(title: String, body: String) async { func sendGenericNotification(title: String, body: String) async {
guard configManager.notificationsEnabled else { return } guard configProvider?.notificationsEnabled ?? false else { return }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
@ -352,6 +411,30 @@ final class NotificationService: NSObject {
deliverNotification(content, identifier: "generic-\(UUID().uuidString)") 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 /// Open System Settings to the Notifications pane
func openNotificationSettings() { func openNotificationSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") { if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
@ -364,7 +447,7 @@ final class NotificationService: NSObject {
self.preferences = prefs self.preferences = prefs
// Update ConfigManager // Update ConfigManager
configManager.updateNotificationPreferences( configProvider?.updateNotificationPreferences(
sessionStart: prefs.sessionStart, sessionStart: prefs.sessionStart,
sessionExit: prefs.sessionExit, sessionExit: prefs.sessionExit,
commandCompletion: prefs.commandCompletion, commandCompletion: prefs.commandCompletion,
@ -424,7 +507,8 @@ final class NotificationService: NSObject {
} }
private func connect() { 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 { guard !isConnected else {
logger.info("Already connected to notification service") logger.info("Already connected to notification service")
return return
@ -432,15 +516,19 @@ final class NotificationService: NSObject {
// When auth mode is "none", we can connect without a token. // 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. // In any other auth mode, a token is required for the local Mac app to connect.
if serverManager.authMode != "none", serverManager.localAuthToken == nil { guard let serverProvider = self.serverProvider else {
logger logger.error("Server provider is not available")
.error("No auth token available for notification service in auth mode '\(self.serverManager.authMode)'")
return return
} }
let eventsURL = "http://localhost:\(self.serverManager.port)/api/events" if serverProvider.authMode != "none", serverProvider.localAuthToken == nil {
logger.info("📡 Attempting to connect to SSE endpoint: \(eventsURL)") logger.error("No auth token available for notification service in auth mode '\(serverProvider.authMode)'")
return
}
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 { guard let url = URL(string: eventsURL) else {
logger.error("Invalid events URL: \(eventsURL)") logger.error("Invalid events URL: \(eventsURL)")
return return
@ -454,11 +542,13 @@ final class NotificationService: NSObject {
// Add authorization header if auth token is available. // Add authorization header if auth token is available.
// When auth mode is "none", there's no token, and that's okay. // 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)" 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 { } 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 // Add custom header to indicate this is the Mac app
@ -468,8 +558,9 @@ final class NotificationService: NSObject {
eventSource?.onOpen = { [weak self] in eventSource?.onOpen = { [weak self] in
Task { @MainActor in Task { @MainActor in
self?.logger.info("✅ Connected to notification event stream") guard let self else { return }
self?.isConnected = true self.logger.info("✅ Connected to notification event stream")
self.isConnected = true
// Post notification for UI update // Post notification for UI update
NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil)
} }
@ -477,10 +568,11 @@ final class NotificationService: NSObject {
eventSource?.onError = { [weak self] error in eventSource?.onError = { [weak self] error in
Task { @MainActor in Task { @MainActor in
guard let self else { return }
if let error { 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 // Post notification for UI update
NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil)
// Don't reconnect here - let server state changes trigger reconnection // Don't reconnect here - let server state changes trigger reconnection
@ -489,11 +581,9 @@ final class NotificationService: NSObject {
eventSource?.onMessage = { [weak self] event in eventSource?.onMessage = { [weak self] event in
Task { @MainActor in Task { @MainActor in
self?.logger guard let self else { return }
.info( self.logger.info("🎯 EventSource onMessage fired! Event type: \(event.event ?? "default", privacy: .public), Has data: \(event.data != nil, privacy: .public)")
"🎯 EventSource onMessage fired! Event type: \(event.event ?? "default"), Has data: \(event.data != nil)" await self.handleEvent(event)
)
self?.handleEvent(event)
} }
} }
@ -509,7 +599,7 @@ final class NotificationService: NSObject {
NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil) NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil)
} }
private func handleEvent(_ event: Event) { private func handleEvent(_ event: Event) async {
guard let data = event.data else { guard let data = event.data else {
logger.warning("Received event with no data") logger.warning("Received event with no data")
return return
@ -536,42 +626,42 @@ final class NotificationService: NSObject {
switch type { switch type {
case "session-start": case "session-start":
logger.info("🚀 Processing session-start event") logger.info("🚀 Processing session-start event")
if configManager.notificationsEnabled && preferences.sessionStart { if configProvider?.notificationsEnabled ?? false && preferences.sessionStart {
handleSessionStart(json) handleSessionStart(json)
} else { } else {
logger.debug("Session start notifications disabled") logger.debug("Session start notifications disabled")
} }
case "session-exit": case "session-exit":
logger.info("🏁 Processing session-exit event") logger.info("🏁 Processing session-exit event")
if configManager.notificationsEnabled && preferences.sessionExit { if configProvider?.notificationsEnabled ?? false && preferences.sessionExit {
handleSessionExit(json) handleSessionExit(json)
} else { } else {
logger.debug("Session exit notifications disabled") logger.debug("Session exit notifications disabled")
} }
case "command-finished": case "command-finished":
logger.info("✅ Processing command-finished event") logger.info("✅ Processing command-finished event")
if configManager.notificationsEnabled && preferences.commandCompletion { if configProvider?.notificationsEnabled ?? false && preferences.commandCompletion {
handleCommandFinished(json) handleCommandFinished(json)
} else { } else {
logger.debug("Command completion notifications disabled") logger.debug("Command completion notifications disabled")
} }
case "command-error": case "command-error":
logger.info("❌ Processing command-error event") logger.info("❌ Processing command-error event")
if configManager.notificationsEnabled && preferences.commandError { if configProvider?.notificationsEnabled ?? false && preferences.commandError {
handleCommandError(json) handleCommandError(json)
} else { } else {
logger.debug("Command error notifications disabled") logger.debug("Command error notifications disabled")
} }
case "bell": case "bell":
logger.info("🔔 Processing bell event") logger.info("🔔 Processing bell event")
if configManager.notificationsEnabled && preferences.bell { if configProvider?.notificationsEnabled ?? false && preferences.bell {
handleBell(json) handleBell(json)
} else { } else {
logger.debug("Bell notifications disabled") logger.debug("Bell notifications disabled")
} }
case "claude-turn": case "claude-turn":
logger.info("💬 Processing claude-turn event") logger.info("💬 Processing claude-turn event")
if configManager.notificationsEnabled && preferences.claudeTurn { if configProvider?.notificationsEnabled ?? false && preferences.claudeTurn {
handleClaudeTurn(json) handleClaudeTurn(json)
} else { } else {
logger.debug("Claude turn notifications disabled") logger.debug("Claude turn notifications disabled")
@ -586,7 +676,7 @@ final class NotificationService: NSObject {
logger.warning("Unknown event type: \(type)") logger.warning("Unknown event type: \(type)")
} }
} catch { } 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]) { 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 title = json["title"] as? String ?? "VibeTunnel Test"
let body = json["body"] as? String ?? "Server-side notifications are working correctly!" let body = json["body"] as? String ?? "Server-side notifications are working correctly!"
let message = json["message"] as? String let message = json["message"] as? String
@ -773,11 +863,12 @@ final class NotificationService: NSObject {
private func deliverNotification(_ content: UNNotificationContent, identifier: String) { private func deliverNotification(_ content: UNNotificationContent, identifier: String) {
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { [weak self] error in Task { @MainActor in
if let error { do {
self?.logger.error("Failed to deliver notification: \(error)") try await UNUserNotificationCenter.current().add(request)
} else { self.logger.debug("Notification delivered: \(identifier, privacy: .public)")
self?.logger.debug("Notification delivered: \(identifier)") } 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) deliverNotification(content, identifier: identifier)
// Schedule automatic dismissal // 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]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier])
} }
} }
@ -798,17 +890,21 @@ final class NotificationService: NSObject {
// MARK: - Cleanup // MARK: - Cleanup
private func scheduleNotificationCleanup(for key: String, after seconds: TimeInterval) { private func scheduleNotificationCleanup(for key: String, after seconds: TimeInterval) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in Task { @MainActor in
self?.recentlyNotifiedSessions.remove(key) 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 /// Send a test notification through the server to verify the full flow
@MainActor
func sendServerTestNotification() async { func sendServerTestNotification() async {
logger.info("🧪 Sending test notification through server...") 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 // Check if server is running
guard serverManager.isRunning else { guard serverProvider?.isRunning ?? false else {
logger.error("❌ Cannot send test notification - server is not running") logger.error("❌ Cannot send test notification - server is not running")
return return
} }
@ -816,32 +912,27 @@ final class NotificationService: NSObject {
// If not connected to SSE, try to connect first // If not connected to SSE, try to connect first
if !isConnected { if !isConnected {
logger.warning("⚠️ Not connected to SSE endpoint, attempting to connect...") logger.warning("⚠️ Not connected to SSE endpoint, attempting to connect...")
await MainActor.run { connect()
connect()
}
// Give it a moment to connect // Give it a moment to connect
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
} }
// Log server info // Log server info
logger logger.info("Server info - Port: \(self.serverProvider?.port ?? "unknown"), Running: \(self.serverProvider?.isRunning ?? false), SSE Connected: \(self.isConnected)")
.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 { guard let url = serverProvider?.buildURL(endpoint: "/api/test-notification") else {
logger.error("❌ Failed to build test notification URL") logger.error("❌ Failed to build test notification URL")
return 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) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Add auth token if available // Add auth token if available
if let authToken = serverManager.localAuthToken { if let authToken = serverProvider?.localAuthToken {
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
logger.debug("Added auth token to request") 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) let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse { 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 { if httpResponse.statusCode == 200 {
logger.info("✅ Server test notification sent successfully") logger.info("✅ Server test notification sent successfully")
if let responseData = String(data: data, encoding: .utf8) { 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 { } else {
logger.error("❌ Server test notification failed with status: \(httpResponse.statusCode)") logger.error("❌ Server test notification failed with status: \(httpResponse.statusCode)")
if let errorData = String(data: data, encoding: .utf8) { 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 // The cleanup will happen when the EventSource is deallocated
// NotificationCenter observers are automatically removed on deinit in modern Swift // 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()
}
} }

View file

@ -176,6 +176,8 @@ class ServerManager {
// Ensure our state is synced // Ensure our state is synced
isRunning = true isRunning = true
lastError = nil lastError = nil
// Start notification service if server is already running
await NotificationService.shared.start()
return return
case .starting: case .starting:
logger.info("Server is already starting") logger.info("Server is already starting")

View file

@ -182,12 +182,12 @@ struct NotificationSettingsView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
Button("Test Notification") { Button("Test Notification") {
Task { Task { @MainActor in
isTestingNotification = true isTestingNotification = true
// Use server test notification to verify the full flow // Use server test notification to verify the full flow
await notificationService.sendServerTestNotification() await notificationService.sendServerTestNotification()
// Reset button state after a delay // Reset button state after a delay
try? await Task.sleep(nanoseconds: 1_000_000_000) await Task.yield()
isTestingNotification = false isTestingNotification = false
} }
} }

View file

@ -143,7 +143,7 @@ struct VibeTunnelApp: App {
/// URL scheme handling, and user notification management. Acts as the central /// URL scheme handling, and user notification management. Acts as the central
/// coordinator for application-wide events and services. /// coordinator for application-wide events and services.
@MainActor @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
// Needed for menu item highlight hack // Needed for menu item highlight hack
weak static var shared: AppDelegate? weak static var shared: AppDelegate?
override init() { override init() {
@ -205,9 +205,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
// Initialize Sparkle updater manager // Initialize Sparkle updater manager
sparkleUpdaterManager = SparkleUpdaterManager.shared sparkleUpdaterManager = SparkleUpdaterManager.shared
// Set up notification center delegate
UNUserNotificationCenter.current().delegate = self
// Initialize dock icon visibility through DockIconManager // Initialize dock icon visibility through DockIconManager
DockIconManager.shared.updateDockVisibility() DockIconManager.shared.updateDockVisibility()
@ -484,36 +481,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
logger.info("🚨 applicationWillTerminate completed quickly") 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 /// Set up lightweight cleanup system for cloudflared processes
private func setupMultiLayerCleanup() { private func setupMultiLayerCleanup() {
logger.info("🛡️ Setting up cloudflared cleanup system") logger.info("🛡️ Setting up cloudflared cleanup system")

View file

@ -527,8 +527,19 @@ export class VibeTunnelApp extends LitElement {
// Initialize push notification service always // Initialize push notification service always
// It handles its own permission checks and user preferences // It handles its own permission checks and user preferences
logger.log('Initializing push notification service...');
await pushNotificationService.initialize(); 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 // Initialize control event service for real-time notifications
this.controlEventService = getControlEventService(authClient); this.controlEventService = getControlEventService(authClient);
this.controlEventService.connect(); this.controlEventService.connect();

View file

@ -30,7 +30,6 @@ const logger = createLogger('lifecycle-event-manager');
export type { LifecycleEventManagerCallbacks } from './interfaces.js'; export type { LifecycleEventManagerCallbacks } from './interfaces.js';
export class LifecycleEventManager extends ManagerEventEmitter { export class LifecycleEventManager extends ManagerEventEmitter {
private sessionViewElement: HTMLElement | null = null;
private callbacks: LifecycleEventManagerCallbacks | null = null; private callbacks: LifecycleEventManagerCallbacks | null = null;
private session: Session | null = null; private session: Session | null = null;
private touchStartX = 0; private touchStartX = 0;

View file

@ -101,6 +101,8 @@ export class Settings extends LitElement {
this.requestUpdate(); this.requestUpdate();
// Discover repositories when settings are opened // Discover repositories when settings are opened
this.discoverRepositories(); this.discoverRepositories();
// Refresh notification state when dialog opens
this.refreshNotificationState();
} else { } else {
document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keydown', this.handleKeyDown);
} }
@ -129,18 +131,46 @@ export class Settings extends LitElement {
this.subscription = pushNotificationService.getSubscription(); this.subscription = pushNotificationService.getSubscription();
this.notificationPreferences = await pushNotificationService.loadPreferences(); 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 // Listen for changes
this.permissionChangeUnsubscribe = pushNotificationService.onPermissionChange((permission) => { this.permissionChangeUnsubscribe = pushNotificationService.onPermissionChange((permission) => {
this.permission = permission; this.permission = permission;
this.requestUpdate();
}); });
this.subscriptionChangeUnsubscribe = pushNotificationService.onSubscriptionChange( this.subscriptionChangeUnsubscribe = pushNotificationService.onSubscriptionChange(
(subscription) => { (subscription) => {
this.subscription = subscription; this.subscription = subscription;
this.requestUpdate();
} }
); );
} }
private async refreshNotificationState(): Promise<void> {
// 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) { updated(changedProperties: PropertyValues) {
super.updated(changedProperties); super.updated(changedProperties);
@ -247,10 +277,24 @@ export class Settings extends LitElement {
// Enable notifications // Enable notifications
const permission = await pushNotificationService.requestPermission(); const permission = await pushNotificationService.requestPermission();
if (permission === 'granted') { 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(); const subscription = await pushNotificationService.subscribe();
if (subscription) { if (subscription) {
this.notificationPreferences = { ...this.notificationPreferences, enabled: true };
await pushNotificationService.savePreferences(this.notificationPreferences); await pushNotificationService.savePreferences(this.notificationPreferences);
// Show welcome notification
await this.showWelcomeNotification();
this.dispatchEvent(new CustomEvent('notifications-enabled')); this.dispatchEvent(new CustomEvent('notifications-enabled'));
} else { } else {
this.dispatchEvent( this.dispatchEvent(
@ -263,27 +307,106 @@ export class Settings extends LitElement {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('error', { new CustomEvent('error', {
detail: detail:
permission === 'denied' 'Notification permission denied. Please enable notifications in your browser settings.',
? 'Notifications permission denied'
: 'Notifications permission not granted',
}) })
); );
} }
} }
} catch (error) {
logger.error('Failed to toggle notifications:', error);
this.dispatchEvent(
new CustomEvent('error', {
detail: 'Failed to toggle notifications',
})
);
} finally { } finally {
this.isLoading = false; 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() { private async handleTestNotification() {
if (this.testingNotification) return; if (this.testingNotification) return;
this.testingNotification = true; this.testingNotification = true;
try { 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( this.dispatchEvent(
new CustomEvent('success', { 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 { } finally {
@ -299,6 +422,29 @@ export class Settings extends LitElement {
await pushNotificationService.savePreferences(this.notificationPreferences); await pushNotificationService.savePreferences(this.notificationPreferences);
} }
private async showWelcomeNotification(): Promise<void> {
// 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) { private handleAppPreferenceChange(key: keyof AppPreferences, value: boolean | string) {
// Update locally // Update locally
this.appPreferences = { ...this.appPreferences, [key]: value }; 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. Tap the share button in Safari and select "Add to Home Screen" to enable push notifications.
</p> </p>
` `
: html` : !window.isSecureContext
? html`
<p class="text-sm text-status-warning mb-2">
Push notifications require a secure connection
</p>
<p class="text-xs text-status-warning opacity-80 mb-2">
You're accessing VibeTunnel via ${window.location.protocol}//${window.location.hostname}
</p>
<p class="text-xs text-status-info opacity-90">
To enable notifications, access VibeTunnel using:
<br> https://${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}
<br> http://localhost:${window.location.port || '4020'}
<br> http://127.0.0.1:${window.location.port || '4020'}
</p>
`
: html`
<p class="text-sm text-status-warning"> <p class="text-sm text-status-warning">
Push notifications are not supported in this browser. Push notifications are not supported in this browser.
</p> </p>
@ -509,12 +670,33 @@ export class Settings extends LitElement {
<button <button
class="btn-secondary text-xs px-3 py-1.5" class="btn-secondary text-xs px-3 py-1.5"
@click=${this.handleTestNotification} @click=${this.handleTestNotification}
?disabled=${!canTest || this.testingNotification} ?disabled=${this.testingNotification || !canTest}
title=${!canTest ? 'Enable notifications first' : 'Send test notification'}
> >
${this.testingNotification ? 'Sending...' : 'Test Notification'} ${this.testingNotification ? 'Testing...' : 'Test Notification'}
</button> </button>
</div> </div>
<!-- Debug section (only in development) -->
${
process.env.NODE_ENV === 'development'
? html`
<div class="mt-3 pt-3 border-t border-border/50">
<p class="text-xs text-muted mb-2">Debug Information</p>
<div class="text-xs space-y-1">
<div>Permission: ${this.permission}</div>
<div>Subscription: ${this.subscription ? 'Active' : 'None'}</div>
<div>Preferences: ${this.notificationPreferences.enabled ? 'Enabled' : 'Disabled'}</div>
<button
class="btn-secondary text-xs px-2 py-1 mt-2"
@click=${() => this.handleForceRefresh()}
>
Force Refresh
</button>
</div>
</div>
`
: ''
}
` `
: '' : ''
} }

View file

@ -628,11 +628,11 @@ export class TerminalQuickKeys extends LitElement {
<!-- Regular row 2 --> <!-- Regular row 2 -->
<div class="flex gap-0.5 mb-0.5 "> <div class="flex gap-0.5 mb-0.5 ">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map( ${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
({ key, label, modifier, combo, special, toggle }) => html` ({ key, label, modifier, combo, toggle }) => html`
<button <button
type="button" type="button"
tabindex="-1" tabindex="-1"
class="quick-key-btn ${this.getButtonFontClass(label)} min-w-0 ${this.getButtonSizeClass(label)} bg-bg-tertiary text-primary font-mono rounded border border-border hover:bg-surface hover:border-primary transition-all whitespace-nowrap ${modifier ? 'modifier-key' : ''} ${combo ? 'combo-key' : ''} ${special ? 'special-key' : ''} ${toggle ? 'toggle-key' : ''} ${toggle && this.showFunctionKeys ? 'active' : ''}" class="quick-key-btn ${this.getButtonFontClass(label)} min-w-0 ${this.getButtonSizeClass(label)} bg-bg-tertiary text-primary font-mono rounded border border-border hover:bg-surface hover:border-primary transition-all whitespace-nowrap ${modifier ? 'modifier-key' : ''} ${combo ? 'combo-key' : ''} ${toggle ? 'toggle-key' : ''} ${toggle && this.showFunctionKeys ? 'active' : ''}"
@mousedown=${(e: Event) => { @mousedown=${(e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -647,12 +647,12 @@ export class TerminalQuickKeys extends LitElement {
if (key === 'Paste') { if (key === 'Paste') {
this.handlePasteImmediate(e); this.handlePasteImmediate(e);
} else { } else {
this.handleKeyPress(key, modifier || combo, special, false, e); this.handleKeyPress(key, modifier || combo, false, false, e);
} }
}} }}
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
if (e.detail !== 0) { if (e.detail !== 0) {
this.handleKeyPress(key, modifier || combo, special, false, e); this.handleKeyPress(key, modifier || combo, false, false, e);
} }
}} }}
> >
@ -693,11 +693,11 @@ export class TerminalQuickKeys extends LitElement {
<!-- Row 3 - Additional special characters (always visible) --> <!-- Row 3 - Additional special characters (always visible) -->
<div class="flex gap-0.5 "> <div class="flex gap-0.5 ">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map( ${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
({ key, label, modifier, combo, special }) => html` ({ key, label, modifier, combo }) => html`
<button <button
type="button" type="button"
tabindex="-1" tabindex="-1"
class="quick-key-btn ${this.getButtonFontClass(label)} min-w-0 ${this.getButtonSizeClass(label)} bg-bg-tertiary text-primary font-mono rounded border border-border hover:bg-surface hover:border-primary transition-all whitespace-nowrap ${modifier ? 'modifier-key' : ''} ${combo ? 'combo-key' : ''} ${special ? 'special-key' : ''} ${modifier && key === 'Option' && this.activeModifiers.has('Option') ? 'active' : ''}" class="quick-key-btn ${this.getButtonFontClass(label)} min-w-0 ${this.getButtonSizeClass(label)} bg-bg-tertiary text-primary font-mono rounded border border-border hover:bg-surface hover:border-primary transition-all whitespace-nowrap ${modifier ? 'modifier-key' : ''} ${combo ? 'combo-key' : ''} ${modifier && key === 'Option' && this.activeModifiers.has('Option') ? 'active' : ''}"
@mousedown=${(e: Event) => { @mousedown=${(e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -709,11 +709,11 @@ export class TerminalQuickKeys extends LitElement {
@touchend=${(e: Event) => { @touchend=${(e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.handleKeyPress(key, modifier || combo, special, false, e); this.handleKeyPress(key, modifier || combo, false, false, e);
}} }}
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
if (e.detail !== 0) { if (e.detail !== 0) {
this.handleKeyPress(key, modifier || combo, special, false, e); this.handleKeyPress(key, modifier || combo, false, false, e);
} }
}} }}
> >

View file

@ -114,7 +114,10 @@ const createMockWindow = () => ({
addEventListener: vi.fn(), addEventListener: vi.fn(),
location: { location: {
origin: 'http://localhost:3000', origin: 'http://localhost:3000',
hostname: 'localhost',
protocol: 'http:',
}, },
isSecureContext: true, // Mock secure context for tests (localhost is considered secure)
}); });
let mockWindow = createMockWindow(); let mockWindow = createMockWindow();
@ -135,6 +138,26 @@ describe('PushNotificationService', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Set up default fetch mock for VAPID key
global.fetch = vi.fn().mockImplementation((url) => {
if (typeof url === 'string' && url.includes('/api/push/vapid-public-key')) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
publicKey:
'BLhSYXCVq1lX0hQ7T_Qt8-_s9k2jJqnGPtCT3kY_SrUhqG4_7FscqLvX0XkH8DqR6fF0vAl_6nQPYe3xt9zBHUE',
enabled: true,
}),
});
}
// Default response for other endpoints
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
});
});
// Ensure any pending promises are resolved // Ensure any pending promises are resolved
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
@ -204,6 +227,19 @@ describe('PushNotificationService', () => {
testService.preferences = null; testService.preferences = null;
testService.initializationPromise = null; testService.initializationPromise = null;
testService.vapidPublicKey = 'test-vapid-key'; testService.vapidPublicKey = 'test-vapid-key';
// Set up default mock for serverConfigService
(serverConfigService.getNotificationPreferences as vi.Mock).mockResolvedValue({
enabled: false,
sessionExit: true,
sessionStart: true,
commandError: true,
commandCompletion: false,
bell: true,
claudeTurn: false,
soundEnabled: true,
vibrationEnabled: false,
});
}); });
afterEach(async () => { afterEach(async () => {
@ -534,7 +570,7 @@ describe('PushNotificationService', () => {
await pushNotificationService.initialize(); await pushNotificationService.initialize();
// Capture the event handler // Capture the event handler
let testNotificationHandler: (data: any) => void; let testNotificationHandler: ((data: unknown) => void) | undefined;
(notificationEventService.on as vi.Mock).mockImplementation((event, handler) => { (notificationEventService.on as vi.Mock).mockImplementation((event, handler) => {
if (event === 'test-notification') { if (event === 'test-notification') {
testNotificationHandler = handler; testNotificationHandler = handler;
@ -547,7 +583,7 @@ describe('PushNotificationService', () => {
// Simulate receiving the event // Simulate receiving the event
await new Promise((resolve) => setTimeout(resolve, 100)); // allow time for listener to be registered await new Promise((resolve) => setTimeout(resolve, 100)); // allow time for listener to be registered
expect(testNotificationHandler!).toBeDefined(); expect(testNotificationHandler).toBeDefined();
testNotificationHandler?.({ testNotificationHandler?.({
title: 'VibeTunnel Test', title: 'VibeTunnel Test',
body: 'Push notifications are working correctly!', body: 'Push notifications are working correctly!',
@ -575,7 +611,7 @@ describe('PushNotificationService', () => {
await pushNotificationService.initialize(); await pushNotificationService.initialize();
// Capture the event handler // Capture the event handler
let testNotificationHandler: (data: any) => void; let testNotificationHandler: ((data: unknown) => void) | undefined;
(notificationEventService.on as vi.Mock).mockImplementation((event, handler) => { (notificationEventService.on as vi.Mock).mockImplementation((event, handler) => {
if (event === 'test-notification') { if (event === 'test-notification') {
testNotificationHandler = handler; testNotificationHandler = handler;
@ -588,7 +624,7 @@ describe('PushNotificationService', () => {
// Simulate receiving the event // Simulate receiving the event
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
expect(testNotificationHandler!).toBeDefined(); expect(testNotificationHandler).toBeDefined();
testNotificationHandler?.({}); testNotificationHandler?.({});
await testPromise; await testPromise;
@ -604,10 +640,39 @@ describe('PushNotificationService', () => {
}); });
describe('sendTestNotification', () => { describe('sendTestNotification', () => {
beforeEach(async () => {
// Initialize the service with mocked subscription
const mockSubscription = {
endpoint: 'https://fcm.googleapis.com/test',
getKey: vi.fn((name: string) => {
if (name === 'p256dh') return new Uint8Array([1, 2, 3]);
if (name === 'auth') return new Uint8Array([4, 5, 6]);
return null;
}),
};
mockServiceWorkerRegistration.pushManager.getSubscription.mockResolvedValue(mockSubscription);
await pushNotificationService.initialize();
});
it('should send a test notification via server', async () => { it('should send a test notification via server', async () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockImplementation((url) => {
ok: true, if (url === '/api/push/status') {
json: () => Promise.resolve({ success: true }), return Promise.resolve({
ok: true,
json: () => Promise.resolve({ enabled: true, configured: true, subscriptions: 1 }),
});
}
if (url === '/api/push/test') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true }),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
});
}); });
await pushNotificationService.sendTestNotification('Test message'); await pushNotificationService.sendTestNotification('Test message');
@ -622,10 +687,24 @@ describe('PushNotificationService', () => {
}); });
it('should handle test notification failure', async () => { it('should handle test notification failure', async () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockImplementation((url) => {
ok: false, if (url === '/api/push/status') {
status: 500, return Promise.resolve({
statusText: 'Internal Server Error', ok: true,
json: () => Promise.resolve({ enabled: true, configured: true, subscriptions: 1 }),
});
}
if (url === '/api/push/test') {
return Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Server Error'),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
});
}); });
await expect(pushNotificationService.sendTestNotification()).rejects.toThrow( await expect(pushNotificationService.sendTestNotification()).rejects.toThrow(

View file

@ -1,7 +1,10 @@
import type { PushSubscription } from '../../shared/types'; import type { PushSubscription } from '../../shared/types';
import { HttpMethod } from '../../shared/types'; import { HttpMethod } from '../../shared/types';
import type { NotificationPreferences } from '../../types/config.js'; import type { NotificationPreferences } from '../../types/config.js';
import { DEFAULT_NOTIFICATION_PREFERENCES } from '../../types/config.js'; import {
DEFAULT_NOTIFICATION_PREFERENCES,
RECOMMENDED_NOTIFICATION_PREFERENCES,
} from '../../types/config.js';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { authClient } from './auth-client'; import { authClient } from './auth-client';
import { notificationEventService } from './notification-event-service'; import { notificationEventService } from './notification-event-service';
@ -65,6 +68,15 @@ export class PushNotificationService {
return; return;
} }
// Check if we're in a secure context (HTTPS or localhost)
// Service workers require HTTPS except for localhost/127.0.0.1
if (!window.isSecureContext) {
logger.warn(
'Push notifications require HTTPS or localhost. Current context is not secure.'
);
return;
}
// Fetch VAPID public key from server // Fetch VAPID public key from server
await this.fetchVapidPublicKey(); await this.fetchVapidPublicKey();
@ -86,6 +98,11 @@ export class PushNotificationService {
// Get existing subscription if any // Get existing subscription if any
this.pushSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription(); this.pushSubscription = await this.serviceWorkerRegistration.pushManager.getSubscription();
logger.log('Existing push subscription found:', {
hasSubscription: !!this.pushSubscription,
endpoint: `${this.pushSubscription?.endpoint?.substring(0, 50)}...`,
});
// Listen for service worker messages // Listen for service worker messages
navigator.serviceWorker.addEventListener( navigator.serviceWorker.addEventListener(
'message', 'message',
@ -168,27 +185,76 @@ export class PushNotificationService {
*/ */
private async autoResubscribe(): Promise<void> { private async autoResubscribe(): Promise<void> {
try { try {
// Don't wait for initialization here - we're already in the initialization process!
// Load saved preferences // Load saved preferences
const preferences = await this.loadPreferences(); const preferences = await this.loadPreferences();
logger.log('Auto-resubscribe checking preferences:', {
enabled: preferences.enabled,
hasPermission: this.getPermission() === 'granted',
hasServiceWorker: !!this.serviceWorkerRegistration,
hasVapidKey: !!this.vapidPublicKey,
hasExistingSubscription: !!this.pushSubscription,
});
// Check if notifications were previously enabled // Check if notifications were previously enabled
if (preferences.enabled) { if (preferences.enabled) {
// Check if we have permission but no subscription logger.log('Notifications were previously enabled, checking subscription state...');
// Check if we have permission
const permission = this.getPermission(); const permission = this.getPermission();
if (permission === 'granted' && !this.pushSubscription) { if (permission !== 'granted') {
logger.log('Auto-resubscribing to push notifications based on saved preferences'); logger.warn('Permission not granted, cannot auto-resubscribe');
// Update preferences to reflect the failed state
preferences.enabled = false;
await this.savePreferences(preferences);
return;
}
// Ensure service worker is ready and VAPID key is available
if (!this.serviceWorkerRegistration) {
logger.warn('Service worker not ready, cannot auto-resubscribe');
return;
}
if (!this.vapidPublicKey) {
logger.warn('VAPID key not available, cannot auto-resubscribe');
return;
}
// Check current subscription state from push manager
if (!this.pushSubscription) {
logger.log('No active subscription found, attempting to resubscribe...');
// Attempt to resubscribe // Attempt to resubscribe
const subscription = await this.subscribe(); const subscription = await this.subscribe();
if (subscription) { if (subscription) {
logger.log('Successfully auto-resubscribed to push notifications'); logger.log('Successfully auto-resubscribed to push notifications');
// Notify listeners that subscription is now active
this.notifySubscriptionChange(subscription);
// Show a welcome notification to confirm notifications are working
await this.showWelcomeNotification();
} else { } else {
logger.warn('Failed to auto-resubscribe, user will need to manually enable'); logger.warn('Failed to auto-resubscribe, user will need to manually enable');
// Update preferences to reflect the failed state // Update preferences to reflect the failed state
preferences.enabled = false; preferences.enabled = false;
await this.savePreferences(preferences); await this.savePreferences(preferences);
} }
} else {
logger.log('Active subscription already exists');
// Convert and notify listeners about the existing subscription
const subscription = this.pushSubscriptionToInterface(this.pushSubscription);
this.notifySubscriptionChange(subscription);
// Sync subscription with server to ensure it's registered
await this.sendSubscriptionToServer(subscription);
} }
} else {
logger.log('Notifications not previously enabled, skipping auto-resubscribe');
} }
} catch (error) { } catch (error) {
logger.error('Error during auto-resubscribe:', error); logger.error('Error during auto-resubscribe:', error);
@ -323,6 +389,14 @@ export class PushNotificationService {
return false; return false;
} }
// Check if we're on HTTPS or localhost
// Service workers require HTTPS except for localhost/127.0.0.1
const isSecureContext = window.isSecureContext;
if (!isSecureContext) {
logger.warn('Push notifications require HTTPS or localhost');
return false;
}
// iOS Safari PWA specific detection // iOS Safari PWA specific detection
// iOS Safari supports push notifications only in standalone PWA mode (iOS 16.4+) // iOS Safari supports push notifications only in standalone PWA mode (iOS 16.4+)
if (this.isIOSSafari()) { if (this.isIOSSafari()) {
@ -386,25 +460,34 @@ export class PushNotificationService {
} }
}, 5000); // 5 second timeout }, 5000); // 5 second timeout
const unsubscribe = notificationEventService.on('test-notification', async (data: any) => { const unsubscribe = notificationEventService.on(
logger.log('📨 Received test notification via SSE:', data); 'test-notification',
receivedNotification = true; async (data: unknown) => {
clearTimeout(timeout); logger.log('📨 Received test notification via SSE:', data);
unsubscribe(); receivedNotification = true;
clearTimeout(timeout);
unsubscribe();
// Show notification if we have permission // Type guard for notification data
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') { const notificationData = data as { title?: string; body?: string };
await this.serviceWorkerRegistration.showNotification(data.title || 'VibeTunnel Test', {
body: data.body || 'Test notification received via SSE!', // Show notification if we have permission
icon: '/apple-touch-icon.png', if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
badge: '/favicon-32.png', await this.serviceWorkerRegistration.showNotification(
tag: 'vibetunnel-test-sse', notificationData.title || 'VibeTunnel Test',
requireInteraction: false, {
}); body: notificationData.body || 'Test notification received via SSE!',
logger.log('✅ Displayed SSE test notification'); icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-test-sse',
requireInteraction: false,
}
);
logger.log('✅ Displayed SSE test notification');
}
resolve();
} }
resolve(); );
});
}); });
// Send the test notification request to server // Send the test notification request to server
@ -495,6 +578,13 @@ export class PushNotificationService {
return DEFAULT_NOTIFICATION_PREFERENCES; return DEFAULT_NOTIFICATION_PREFERENCES;
} }
/**
* Get recommended notification preferences for new users
*/
getRecommendedPreferences(): NotificationPreferences {
return RECOMMENDED_NOTIFICATION_PREFERENCES;
}
/** /**
* Register callback for permission changes * Register callback for permission changes
*/ */
@ -534,15 +624,20 @@ export class PushNotificationService {
method: HttpMethod.POST, method: HttpMethod.POST,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...authClient.getAuthHeader(),
}, },
body: JSON.stringify(subscription), body: JSON.stringify(subscription),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`); const errorText = await response.text();
throw new Error(
`Server responded with ${response.status}: ${errorText || response.statusText}`
);
} }
logger.log('subscription sent to server'); const result = await response.json();
logger.log('subscription sent to server successfully', result);
} catch (error) { } catch (error) {
logger.error('failed to send subscription to server:', error); logger.error('failed to send subscription to server:', error);
throw error; throw error;
@ -660,23 +755,52 @@ export class PushNotificationService {
*/ */
async sendTestNotification(message?: string): Promise<void> { async sendTestNotification(message?: string): Promise<void> {
try { try {
logger.log('Sending test notification...');
// Validate prerequisites
if (!this.serviceWorkerRegistration) {
throw new Error('Service worker not registered');
}
if (!this.vapidPublicKey) {
throw new Error('VAPID public key not available');
}
if (!this.pushSubscription) {
throw new Error('No active push subscription');
}
// Check server status first
const serverStatus = await this.getServerStatus();
if (!serverStatus.enabled) {
throw new Error('Push notifications disabled on server');
}
if (!serverStatus.configured) {
throw new Error('VAPID keys not configured on server');
}
// Send test notification to server
const response = await fetch('/api/push/test', { const response = await fetch('/api/push/test', {
method: HttpMethod.POST, method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ message }), body: JSON.stringify({
message: message || 'Test notification from VibeTunnel',
}),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`); const errorText = await response.text();
throw new Error(`Server responded with ${response.status}: ${errorText}`);
} }
const result = await response.json(); const result = await response.json();
logger.log('Test notification sent via server:', result); logger.log('Test notification sent successfully:', result);
} catch (error) { } catch (error) {
logger.error('Failed to send test notification via server:', error); logger.error('Failed to send test notification:', error);
throw error; throw error; // Re-throw for the calling code to handle
} }
} }
@ -706,6 +830,74 @@ export class PushNotificationService {
} }
} }
/**
* Show a welcome notification when auto-resubscribed
*/
private async showWelcomeNotification(): Promise<void> {
if (!this.serviceWorkerRegistration) {
return;
}
try {
// Show notification directly
await this.serviceWorkerRegistration.showNotification('VibeTunnel Notifications Active', {
body: "You'll receive notifications for session events",
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-welcome',
requireInteraction: false,
silent: false,
});
logger.log('Welcome notification displayed');
} catch (error) {
logger.error('Failed to show welcome notification:', error);
}
}
/**
* Force refresh subscription state - useful for debugging and manual recovery
*/
async forceRefreshSubscription(): Promise<void> {
try {
logger.log('Force refreshing subscription state');
// Clear current subscription state
this.pushSubscription = null;
// Wait for initialization to complete
await this.waitForInitialization();
// Check if we should auto-resubscribe
const preferences = await this.loadPreferences();
if (preferences.enabled) {
await this.autoResubscribe();
}
logger.log('Subscription state refresh completed');
} catch (error) {
logger.error('Error during subscription refresh:', error);
}
}
/**
* Get current subscription status for debugging
*/
getSubscriptionStatus(): {
hasPermission: boolean;
hasServiceWorker: boolean;
hasVapidKey: boolean;
hasSubscription: boolean;
preferences: NotificationPreferences | null;
} {
return {
hasPermission: this.getPermission() === 'granted',
hasServiceWorker: !!this.serviceWorkerRegistration,
hasVapidKey: !!this.vapidPublicKey,
hasSubscription: !!this.pushSubscription,
preferences: null, // Will be loaded asynchronously
};
}
/** /**
* Clean up service * Clean up service
*/ */

View file

@ -1,7 +1,8 @@
import { type Request, type Response, Router } from 'express'; import { type Request, type Response, Router } from 'express';
import type { BellEventHandler } from '../services/bell-event-handler.js'; import { ServerEventType } from '../../shared/types.js';
import type { PushNotificationService } from '../services/push-notification-service.js'; import type { PushNotificationService } from '../services/push-notification-service.js';
import { PushNotificationStatusService } from '../services/push-notification-status-service.js'; import { PushNotificationStatusService } from '../services/push-notification-status-service.js';
import type { SessionMonitor } from '../services/session-monitor.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
import type { VapidManager } from '../utils/vapid-manager.js'; import type { VapidManager } from '../utils/vapid-manager.js';
@ -10,11 +11,11 @@ const logger = createLogger('push-routes');
export interface CreatePushRoutesOptions { export interface CreatePushRoutesOptions {
vapidManager: VapidManager; vapidManager: VapidManager;
pushNotificationService: PushNotificationService | null; pushNotificationService: PushNotificationService | null;
bellEventHandler?: BellEventHandler; sessionMonitor?: SessionMonitor;
} }
export function createPushRoutes(options: CreatePushRoutesOptions): Router { export function createPushRoutes(options: CreatePushRoutesOptions): Router {
const { vapidManager, pushNotificationService } = options; const { vapidManager, pushNotificationService, sessionMonitor } = options;
const router = Router(); const router = Router();
/** /**
@ -22,6 +23,14 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
*/ */
router.get('/push/vapid-public-key', (_req: Request, res: Response) => { router.get('/push/vapid-public-key', (_req: Request, res: Response) => {
try { try {
// Check if VAPID manager is properly initialized
if (!vapidManager.isEnabled()) {
return res.status(503).json({
error: 'Push notifications not configured',
message: 'VAPID keys not available or service not initialized',
});
}
const publicKey = vapidManager.getPublicKey(); const publicKey = vapidManager.getPublicKey();
if (!publicKey) { if (!publicKey) {
@ -31,13 +40,6 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
}); });
} }
if (!vapidManager.isEnabled()) {
return res.status(503).json({
error: 'Push notifications disabled',
message: 'VAPID configuration incomplete',
});
}
res.json({ res.json({
publicKey, publicKey,
enabled: true, enabled: true,
@ -136,7 +138,7 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
/** /**
* Send test notification * Send test notification
*/ */
router.post('/push/test', async (_req: Request, res: Response) => { router.post('/push/test', async (req: Request, res: Response) => {
if (!pushNotificationService) { if (!pushNotificationService) {
return res.status(503).json({ return res.status(503).json({
error: 'Push notifications not initialized', error: 'Push notifications not initialized',
@ -145,10 +147,12 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
} }
try { try {
const { message } = req.body;
const result = await pushNotificationService.sendNotification({ const result = await pushNotificationService.sendNotification({
type: 'test', type: 'test',
title: '🔔 Test Notification', title: '🔔 Test Notification',
body: 'This is a test notification from VibeTunnel', body: message || 'This is a test notification from VibeTunnel',
icon: '/apple-touch-icon.png', icon: '/apple-touch-icon.png',
badge: '/favicon-32.png', badge: '/favicon-32.png',
tag: 'vibetunnel-test', tag: 'vibetunnel-test',
@ -161,12 +165,27 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
], ],
}); });
// Also emit through SSE if sessionMonitor is available
if (sessionMonitor) {
const testEvent = {
type: ServerEventType.TestNotification,
sessionId: 'test-session',
sessionName: 'Test Notification',
timestamp: new Date().toISOString(),
message: message || 'This is a test notification from VibeTunnel',
title: '🔔 Test Notification',
body: message || 'This is a test notification from VibeTunnel',
};
sessionMonitor.emit('notification', testEvent);
logger.info('✅ Test notification also emitted through SSE');
}
res.json({ res.json({
success: result.success, success: result.success,
sent: result.sent, sent: result.sent,
failed: result.failed, failed: result.failed,
errors: result.errors, errors: result.errors,
message: `Test notification sent to ${result.sent} subscribers`, message: `Test notification sent to ${result.sent} push subscribers${sessionMonitor ? ' and SSE listeners' : ''}`,
}); });
logger.log(`Test notification sent: ${result.sent} successful, ${result.failed} failed`); logger.log(`Test notification sent: ${result.sent} successful, ${result.failed} failed`);
@ -183,18 +202,24 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
* Get service status * Get service status
*/ */
router.get('/push/status', (_req: Request, res: Response) => { router.get('/push/status', (_req: Request, res: Response) => {
if (!pushNotificationService) {
return res.status(503).json({
error: 'Push notifications not initialized',
message: 'Push notification service is not available',
});
}
try { try {
// Return disabled status if services are not available
if (!pushNotificationService || !vapidManager.isEnabled()) {
return res.json({
enabled: false,
configured: false,
hasVapidKeys: false,
totalSubscriptions: 0,
activeSubscriptions: 0,
errors: ['Push notification service not initialized or VAPID not configured'],
});
}
const subscriptions = pushNotificationService.getSubscriptions(); const subscriptions = pushNotificationService.getSubscriptions();
res.json({ res.json({
enabled: vapidManager.isEnabled(), enabled: vapidManager.isEnabled(),
configured: true,
hasVapidKeys: !!vapidManager.getPublicKey(), hasVapidKeys: !!vapidManager.getPublicKey(),
totalSubscriptions: subscriptions.length, totalSubscriptions: subscriptions.length,
activeSubscriptions: subscriptions.filter((sub) => sub.isActive).length, activeSubscriptions: subscriptions.filter((sub) => sub.isActive).length,

View file

@ -1,19 +1,26 @@
import { type Request, type Response, Router } from 'express'; import { type Request, type Response, Router } from 'express';
import { ServerEventType } from '../../shared/types.js'; import { ServerEventType } from '../../shared/types.js';
import type { PushNotificationService } from '../services/push-notification-service.js';
import type { SessionMonitor } from '../services/session-monitor.js'; import type { SessionMonitor } from '../services/session-monitor.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
import { getVersionInfo } from '../version.js'; import { getVersionInfo } from '../version.js';
const logger = createLogger('test-notification'); const logger = createLogger('test-notification');
interface TestNotificationOptions {
sessionMonitor?: SessionMonitor;
pushNotificationService?: PushNotificationService | null;
}
/** /**
* Test notification endpoint to verify the full notification flow * Test notification endpoint to verify the full notification flow
* from server SSE Mac app * from server SSE Mac app AND push notifications
*/ */
export function createTestNotificationRouter(sessionMonitor?: SessionMonitor): Router { export function createTestNotificationRouter(options: TestNotificationOptions): Router {
const { sessionMonitor, pushNotificationService } = options;
const router = Router(); const router = Router();
// POST /api/test-notification - Trigger a test notification through the SSE system // POST /api/test-notification - Trigger a test notification through BOTH SSE and push systems
router.post('/test-notification', async (req: Request, res: Response) => { router.post('/test-notification', async (req: Request, res: Response) => {
logger.info('📨 Test notification requested from client'); logger.info('📨 Test notification requested from client');
logger.debug('Request headers:', req.headers); logger.debug('Request headers:', req.headers);
@ -46,12 +53,44 @@ export function createTestNotificationRouter(sessionMonitor?: SessionMonitor): R
// This will be picked up by the SSE endpoint and sent to all connected clients // This will be picked up by the SSE endpoint and sent to all connected clients
sessionMonitor.emit('notification', testEvent); sessionMonitor.emit('notification', testEvent);
logger.info('✅ Test notification event emitted successfully'); logger.info('✅ Test notification event emitted successfully through SSE');
// Also send through push notification service if available
let pushResult = null;
if (pushNotificationService) {
try {
logger.info('📤 Sending test notification through push service...');
pushResult = await pushNotificationService.sendNotification({
type: 'test',
title: testEvent.title || '🔔 Test Notification',
body: testEvent.body || 'This is a test notification from VibeTunnel',
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-test',
requireInteraction: false,
actions: [
{
action: 'dismiss',
title: 'Dismiss',
},
],
data: {
type: 'test-notification',
sessionId: testEvent.sessionId,
timestamp: testEvent.timestamp,
},
});
logger.info(`✅ Push notification sent to ${pushResult.sent} subscribers`);
} catch (error) {
logger.error('❌ Failed to send push notification:', error);
}
}
res.json({ res.json({
success: true, success: true,
message: 'Test notification sent through SSE', message: 'Test notification sent through SSE and push',
event: testEvent, event: testEvent,
pushResult,
}); });
} catch (error) { } catch (error) {
logger.error('❌ Failed to send test notification:', error); logger.error('❌ Failed to send test notification:', error);

View file

@ -11,6 +11,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { ServerEventType } from '../shared/types.js';
import { apiSocketServer } from './api-socket-server.js'; import { apiSocketServer } from './api-socket-server.js';
import type { AuthenticatedRequest } from './middleware/auth.js'; import type { AuthenticatedRequest } from './middleware/auth.js';
import { createAuthMiddleware } from './middleware/auth.js'; import { createAuthMiddleware } from './middleware/auth.js';
@ -527,6 +528,112 @@ export async function createApp(): Promise<AppInstance> {
logger.debug('Push notifications disabled'); logger.debug('Push notifications disabled');
} }
// Connect SessionMonitor to push notification service
if (sessionMonitor && pushNotificationService) {
logger.info('Connecting SessionMonitor to push notification service');
// Listen for session monitor notifications and send push notifications
sessionMonitor.on('notification', async (event) => {
try {
// Map event types to push notification data
let pushPayload = null;
switch (event.type) {
case ServerEventType.SessionStart:
pushPayload = {
type: 'session-start',
title: '🚀 Session Started',
body: event.sessionName || 'Terminal Session',
};
break;
case ServerEventType.SessionExit:
pushPayload = {
type: 'session-exit',
title: '🏁 Session Ended',
body: event.sessionName || 'Terminal Session',
data: { exitCode: event.exitCode },
};
break;
case ServerEventType.CommandFinished:
pushPayload = {
type: 'command-finished',
title: '✅ Your Turn',
body: event.command || 'Command completed',
data: { duration: event.duration },
};
break;
case ServerEventType.CommandError:
pushPayload = {
type: 'command-error',
title: '❌ Command Failed',
body: event.command || 'Command failed',
data: { exitCode: event.exitCode },
};
break;
case ServerEventType.Bell:
pushPayload = {
type: 'bell',
title: '🔔 Terminal Bell',
body: event.sessionName || 'Terminal',
};
break;
case ServerEventType.ClaudeTurn:
pushPayload = {
type: 'claude-turn',
title: '💬 Your Turn',
body: event.message || 'Claude has finished responding',
};
break;
case ServerEventType.TestNotification:
// Test notifications are already handled by the test endpoint
return;
default:
return; // Skip unknown event types
}
if (pushPayload) {
// Send push notification
const result = await pushNotificationService.sendNotification({
...pushPayload,
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: `vibetunnel-${pushPayload.type}`,
requireInteraction: pushPayload.type === 'command-error',
actions: [
{
action: 'view-session',
title: 'View Session',
},
{
action: 'dismiss',
title: 'Dismiss',
},
],
data: {
...pushPayload.data,
type: pushPayload.type,
sessionId: event.sessionId,
timestamp: event.timestamp,
},
});
logger.debug(
`Push notification sent for ${event.type}: ${result.sent} successful, ${result.failed} failed`
);
}
} catch (error) {
logger.error('Failed to send push notification for SessionMonitor event:', error);
}
});
}
// Initialize HQ components // Initialize HQ components
let remoteRegistry: RemoteRegistry | null = null; let remoteRegistry: RemoteRegistry | null = null;
let hqClient: HQClient | null = null; let hqClient: HQClient | null = null;
@ -929,24 +1036,24 @@ export async function createApp(): Promise<AppInstance> {
app.use('/api/multiplexer', createMultiplexerRoutes({ ptyManager })); app.use('/api/multiplexer', createMultiplexerRoutes({ ptyManager }));
logger.debug('Mounted multiplexer routes'); logger.debug('Mounted multiplexer routes');
// Mount push notification routes // Mount push notification routes - always mount even if VAPID is not initialized
if (vapidManager) { // This ensures proper error responses instead of 404s
app.use( app.use(
'/api', '/api',
createPushRoutes({ createPushRoutes({
vapidManager, vapidManager: vapidManager || new VapidManager(), // Pass a dummy instance if null
pushNotificationService, pushNotificationService,
}) sessionMonitor,
); })
logger.debug('Mounted push notification routes'); );
} logger.debug('Mounted push notification routes');
// Mount events router for SSE streaming // Mount events router for SSE streaming
app.use('/api', createEventsRouter(sessionMonitor)); app.use('/api', createEventsRouter(sessionMonitor));
logger.debug('Mounted events routes'); logger.debug('Mounted events routes');
// Mount test notification router // Mount test notification router
app.use('/api', createTestNotificationRouter(sessionMonitor)); app.use('/api', createTestNotificationRouter({ sessionMonitor, pushNotificationService }));
logger.debug('Mounted test notification routes'); logger.debug('Mounted test notification routes');
// Initialize control socket // Initialize control socket

View file

@ -45,7 +45,8 @@ describe('ZellijManager', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset singleton instance // Reset singleton instance
(ZellijManager as any).instance = undefined; // @ts-ignore - accessing private instance for test reset
ZellijManager.instance = undefined;
zellijManager = ZellijManager.getInstance(mockPtyManager); zellijManager = ZellijManager.getInstance(mockPtyManager);
}); });

View file

@ -71,14 +71,30 @@ export const DEFAULT_QUICK_START_COMMANDS: QuickStartCommand[] = [
export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
enabled: false, enabled: false,
sessionStart: true, sessionStart: false,
sessionExit: true, sessionExit: true,
commandCompletion: true, commandCompletion: false,
commandError: true, commandError: true,
bell: true, bell: true,
claudeTurn: false, claudeTurn: false,
soundEnabled: true, soundEnabled: true,
vibrationEnabled: true, vibrationEnabled: false,
};
/**
* Recommended notification preferences for new users
* These are sensible defaults when notifications are enabled
*/
export const RECOMMENDED_NOTIFICATION_PREFERENCES: NotificationPreferences = {
enabled: true,
sessionStart: false,
sessionExit: true,
commandCompletion: false,
commandError: true,
bell: true,
claudeTurn: true,
soundEnabled: true,
vibrationEnabled: false,
}; };
export const DEFAULT_CONFIG: VibeTunnelConfig = { export const DEFAULT_CONFIG: VibeTunnelConfig = {