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 {
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

View file

@ -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<String>()
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()
}
}

View file

@ -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")

View file

@ -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
}
}

View file

@ -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")

View file

@ -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();

View file

@ -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) {

View file

@ -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;

View file

@ -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<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) {
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<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) {
// 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.
</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">
Push notifications are not supported in this browser.
</p>
@ -509,12 +670,33 @@ export class Settings extends LitElement {
<button
class="btn-secondary text-xs px-3 py-1.5"
@click=${this.handleTestNotification}
?disabled=${!canTest || this.testingNotification}
title=${!canTest ? 'Enable notifications first' : 'Send test notification'}
?disabled=${this.testingNotification || !canTest}
>
${this.testingNotification ? 'Sending...' : 'Test Notification'}
${this.testingNotification ? 'Testing...' : 'Test Notification'}
</button>
</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 -->
<div class="flex gap-0.5 mb-0.5 ">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
({ key, label, modifier, combo, special, toggle }) => html`
({ key, label, modifier, combo, toggle }) => html`
<button
type="button"
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) => {
e.preventDefault();
e.stopPropagation();
@ -647,12 +647,12 @@ export class TerminalQuickKeys extends LitElement {
if (key === 'Paste') {
this.handlePasteImmediate(e);
} else {
this.handleKeyPress(key, modifier || combo, special, false, e);
this.handleKeyPress(key, modifier || combo, false, false, e);
}
}}
@click=${(e: MouseEvent) => {
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) -->
<div class="flex gap-0.5 ">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
({ key, label, modifier, combo, special }) => html`
({ key, label, modifier, combo }) => html`
<button
type="button"
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) => {
e.preventDefault();
e.stopPropagation();
@ -709,11 +709,11 @@ export class TerminalQuickKeys extends LitElement {
@touchend=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
this.handleKeyPress(key, modifier || combo, special, false, e);
this.handleKeyPress(key, modifier || combo, false, false, e);
}}
@click=${(e: MouseEvent) => {
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(),
location: {
origin: 'http://localhost:3000',
hostname: 'localhost',
protocol: 'http:',
},
isSecureContext: true, // Mock secure context for tests (localhost is considered secure)
});
let mockWindow = createMockWindow();
@ -135,6 +138,26 @@ describe('PushNotificationService', () => {
beforeEach(async () => {
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
await new Promise((resolve) => setTimeout(resolve, 0));
@ -204,6 +227,19 @@ describe('PushNotificationService', () => {
testService.preferences = null;
testService.initializationPromise = null;
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 () => {
@ -534,7 +570,7 @@ describe('PushNotificationService', () => {
await pushNotificationService.initialize();
// Capture the event handler
let testNotificationHandler: (data: any) => void;
let testNotificationHandler: ((data: unknown) => void) | undefined;
(notificationEventService.on as vi.Mock).mockImplementation((event, handler) => {
if (event === 'test-notification') {
testNotificationHandler = handler;
@ -547,7 +583,7 @@ describe('PushNotificationService', () => {
// Simulate receiving the event
await new Promise((resolve) => setTimeout(resolve, 100)); // allow time for listener to be registered
expect(testNotificationHandler!).toBeDefined();
expect(testNotificationHandler).toBeDefined();
testNotificationHandler?.({
title: 'VibeTunnel Test',
body: 'Push notifications are working correctly!',
@ -575,7 +611,7 @@ describe('PushNotificationService', () => {
await pushNotificationService.initialize();
// Capture the event handler
let testNotificationHandler: (data: any) => void;
let testNotificationHandler: ((data: unknown) => void) | undefined;
(notificationEventService.on as vi.Mock).mockImplementation((event, handler) => {
if (event === 'test-notification') {
testNotificationHandler = handler;
@ -588,7 +624,7 @@ describe('PushNotificationService', () => {
// Simulate receiving the event
await new Promise((resolve) => setTimeout(resolve, 100));
expect(testNotificationHandler!).toBeDefined();
expect(testNotificationHandler).toBeDefined();
testNotificationHandler?.({});
await testPromise;
@ -604,10 +640,39 @@ describe('PushNotificationService', () => {
});
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 () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
global.fetch = vi.fn().mockImplementation((url) => {
if (url === '/api/push/status') {
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');
@ -622,10 +687,24 @@ describe('PushNotificationService', () => {
});
it('should handle test notification failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
global.fetch = vi.fn().mockImplementation((url) => {
if (url === '/api/push/status') {
return Promise.resolve({
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(

View file

@ -1,7 +1,10 @@
import type { PushSubscription } from '../../shared/types';
import { HttpMethod } from '../../shared/types';
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 { authClient } from './auth-client';
import { notificationEventService } from './notification-event-service';
@ -65,6 +68,15 @@ export class PushNotificationService {
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
await this.fetchVapidPublicKey();
@ -86,6 +98,11 @@ export class PushNotificationService {
// Get existing subscription if any
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
navigator.serviceWorker.addEventListener(
'message',
@ -168,27 +185,76 @@ export class PushNotificationService {
*/
private async autoResubscribe(): Promise<void> {
try {
// Don't wait for initialization here - we're already in the initialization process!
// Load saved preferences
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
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();
if (permission === 'granted' && !this.pushSubscription) {
logger.log('Auto-resubscribing to push notifications based on saved preferences');
if (permission !== 'granted') {
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
const subscription = await this.subscribe();
if (subscription) {
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 {
logger.warn('Failed to auto-resubscribe, user will need to manually enable');
// Update preferences to reflect the failed state
preferences.enabled = false;
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) {
logger.error('Error during auto-resubscribe:', error);
@ -323,6 +389,14 @@ export class PushNotificationService {
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 supports push notifications only in standalone PWA mode (iOS 16.4+)
if (this.isIOSSafari()) {
@ -386,25 +460,34 @@ export class PushNotificationService {
}
}, 5000); // 5 second timeout
const unsubscribe = notificationEventService.on('test-notification', async (data: any) => {
logger.log('📨 Received test notification via SSE:', data);
receivedNotification = true;
clearTimeout(timeout);
unsubscribe();
const unsubscribe = notificationEventService.on(
'test-notification',
async (data: unknown) => {
logger.log('📨 Received test notification via SSE:', data);
receivedNotification = true;
clearTimeout(timeout);
unsubscribe();
// Show notification if we have permission
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
await this.serviceWorkerRegistration.showNotification(data.title || 'VibeTunnel Test', {
body: data.body || 'Test notification received via SSE!',
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-test-sse',
requireInteraction: false,
});
logger.log('✅ Displayed SSE test notification');
// Type guard for notification data
const notificationData = data as { title?: string; body?: string };
// Show notification if we have permission
if (this.serviceWorkerRegistration && this.getPermission() === 'granted') {
await this.serviceWorkerRegistration.showNotification(
notificationData.title || 'VibeTunnel Test',
{
body: notificationData.body || 'Test notification received via SSE!',
icon: '/apple-touch-icon.png',
badge: '/favicon-32.png',
tag: 'vibetunnel-test-sse',
requireInteraction: false,
}
);
logger.log('✅ Displayed SSE test notification');
}
resolve();
}
resolve();
});
);
});
// Send the test notification request to server
@ -495,6 +578,13 @@ export class PushNotificationService {
return DEFAULT_NOTIFICATION_PREFERENCES;
}
/**
* Get recommended notification preferences for new users
*/
getRecommendedPreferences(): NotificationPreferences {
return RECOMMENDED_NOTIFICATION_PREFERENCES;
}
/**
* Register callback for permission changes
*/
@ -534,15 +624,20 @@ export class PushNotificationService {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/json',
...authClient.getAuthHeader(),
},
body: JSON.stringify(subscription),
});
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) {
logger.error('failed to send subscription to server:', error);
throw error;
@ -660,23 +755,52 @@ export class PushNotificationService {
*/
async sendTestNotification(message?: string): Promise<void> {
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', {
method: HttpMethod.POST,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
body: JSON.stringify({
message: message || 'Test notification from VibeTunnel',
}),
});
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();
logger.log('Test notification sent via server:', result);
logger.log('Test notification sent successfully:', result);
} catch (error) {
logger.error('Failed to send test notification via server:', error);
throw error;
logger.error('Failed to send test notification:', 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
*/

View file

@ -1,7 +1,8 @@
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 { PushNotificationStatusService } from '../services/push-notification-status-service.js';
import type { SessionMonitor } from '../services/session-monitor.js';
import { createLogger } from '../utils/logger.js';
import type { VapidManager } from '../utils/vapid-manager.js';
@ -10,11 +11,11 @@ const logger = createLogger('push-routes');
export interface CreatePushRoutesOptions {
vapidManager: VapidManager;
pushNotificationService: PushNotificationService | null;
bellEventHandler?: BellEventHandler;
sessionMonitor?: SessionMonitor;
}
export function createPushRoutes(options: CreatePushRoutesOptions): Router {
const { vapidManager, pushNotificationService } = options;
const { vapidManager, pushNotificationService, sessionMonitor } = options;
const router = Router();
/**
@ -22,6 +23,14 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
*/
router.get('/push/vapid-public-key', (_req: Request, res: Response) => {
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();
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({
publicKey,
enabled: true,
@ -136,7 +138,7 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
/**
* Send test notification
*/
router.post('/push/test', async (_req: Request, res: Response) => {
router.post('/push/test', async (req: Request, res: Response) => {
if (!pushNotificationService) {
return res.status(503).json({
error: 'Push notifications not initialized',
@ -145,10 +147,12 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
}
try {
const { message } = req.body;
const result = await pushNotificationService.sendNotification({
type: 'test',
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',
badge: '/favicon-32.png',
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({
success: result.success,
sent: result.sent,
failed: result.failed,
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`);
@ -183,18 +202,24 @@ export function createPushRoutes(options: CreatePushRoutesOptions): Router {
* Get service status
*/
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 {
// 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();
res.json({
enabled: vapidManager.isEnabled(),
configured: true,
hasVapidKeys: !!vapidManager.getPublicKey(),
totalSubscriptions: subscriptions.length,
activeSubscriptions: subscriptions.filter((sub) => sub.isActive).length,

View file

@ -1,19 +1,26 @@
import { type Request, type Response, Router } from 'express';
import { ServerEventType } from '../../shared/types.js';
import type { PushNotificationService } from '../services/push-notification-service.js';
import type { SessionMonitor } from '../services/session-monitor.js';
import { createLogger } from '../utils/logger.js';
import { getVersionInfo } from '../version.js';
const logger = createLogger('test-notification');
interface TestNotificationOptions {
sessionMonitor?: SessionMonitor;
pushNotificationService?: PushNotificationService | null;
}
/**
* 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();
// 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) => {
logger.info('📨 Test notification requested from client');
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
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({
success: true,
message: 'Test notification sent through SSE',
message: 'Test notification sent through SSE and push',
event: testEvent,
pushResult,
});
} catch (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 { v4 as uuidv4 } from 'uuid';
import { WebSocketServer } from 'ws';
import { ServerEventType } from '../shared/types.js';
import { apiSocketServer } from './api-socket-server.js';
import type { AuthenticatedRequest } 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');
}
// 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
let remoteRegistry: RemoteRegistry | null = null;
let hqClient: HQClient | null = null;
@ -929,24 +1036,24 @@ export async function createApp(): Promise<AppInstance> {
app.use('/api/multiplexer', createMultiplexerRoutes({ ptyManager }));
logger.debug('Mounted multiplexer routes');
// Mount push notification routes
if (vapidManager) {
app.use(
'/api',
createPushRoutes({
vapidManager,
pushNotificationService,
})
);
logger.debug('Mounted push notification routes');
}
// Mount push notification routes - always mount even if VAPID is not initialized
// This ensures proper error responses instead of 404s
app.use(
'/api',
createPushRoutes({
vapidManager: vapidManager || new VapidManager(), // Pass a dummy instance if null
pushNotificationService,
sessionMonitor,
})
);
logger.debug('Mounted push notification routes');
// Mount events router for SSE streaming
app.use('/api', createEventsRouter(sessionMonitor));
logger.debug('Mounted events routes');
// Mount test notification router
app.use('/api', createTestNotificationRouter(sessionMonitor));
app.use('/api', createTestNotificationRouter({ sessionMonitor, pushNotificationService }));
logger.debug('Mounted test notification routes');
// Initialize control socket

View file

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

View file

@ -71,14 +71,30 @@ export const DEFAULT_QUICK_START_COMMANDS: QuickStartCommand[] = [
export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
enabled: false,
sessionStart: true,
sessionStart: false,
sessionExit: true,
commandCompletion: true,
commandCompletion: false,
commandError: true,
bell: true,
claudeTurn: false,
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 = {