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`