mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
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:
parent
32935878d8
commit
4523a21f6c
17 changed files with 960 additions and 216 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue