mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix notification preferences to match web defaults
- Set notificationsEnabled to false by default in ConfigManager (matching TypeScript) - Update NotificationService to check master notifications switch - Update SessionMonitor to use ConfigManager instead of UserDefaults - Fix notification tests to handle existing config files - Add documentation about expected default values
This commit is contained in:
parent
a5d43e8274
commit
69a3ff0714
4 changed files with 488 additions and 356 deletions
|
|
@ -226,7 +226,8 @@ final class ConfigManager {
|
||||||
self.repositoryBasePath = FilePathConstants.defaultRepositoryBasePath
|
self.repositoryBasePath = FilePathConstants.defaultRepositoryBasePath
|
||||||
|
|
||||||
// Set notification defaults to match TypeScript defaults
|
// Set notification defaults to match TypeScript defaults
|
||||||
self.notificationsEnabled = true
|
// Master switch is OFF by default, but individual preferences are set to true
|
||||||
|
self.notificationsEnabled = false // Changed from true to match web defaults
|
||||||
self.notificationSessionStart = true
|
self.notificationSessionStart = true
|
||||||
self.notificationSessionExit = true
|
self.notificationSessionExit = true
|
||||||
self.notificationCommandCompletion = true
|
self.notificationCommandCompletion = true
|
||||||
|
|
|
||||||
|
|
@ -99,34 +99,37 @@ final class NotificationService: NSObject {
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
logger.warning("❌ Notification permissions denied")
|
logger.warning("⚠️ Notification permissions denied by user")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to request notification permissions: \(error)")
|
logger.error("❌ Failed to request notification permissions: \(error)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
case .denied:
|
case .denied:
|
||||||
// Already denied - open System Settings
|
logger.warning("⚠️ Notification permissions previously denied")
|
||||||
logger.info("Opening System Settings to Notifications pane")
|
|
||||||
openNotificationSettings()
|
|
||||||
return false
|
return false
|
||||||
|
|
||||||
case .authorized, .provisional, .ephemeral:
|
case .authorized, .provisional:
|
||||||
// Already authorized - show test notification
|
logger.info("✅ Notification permissions already granted")
|
||||||
logger.info("✅ Notifications already authorized")
|
|
||||||
|
|
||||||
|
// Show test notification
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "VibeTunnel Notifications"
|
content.title = "VibeTunnel Notifications"
|
||||||
content.body = "Notifications are enabled! You'll receive alerts for terminal events."
|
content.body = "Notifications are already enabled! You'll receive alerts for terminal events."
|
||||||
content.sound = getNotificationSound()
|
content.sound = getNotificationSound()
|
||||||
|
|
||||||
deliverNotification(content, identifier: "permission-test-\(UUID().uuidString)")
|
deliverNotification(content, identifier: "permission-test-\(UUID().uuidString)")
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
case .ephemeral:
|
||||||
|
logger.info("ℹ️ Ephemeral notification permissions")
|
||||||
|
return true
|
||||||
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
logger.warning("⚠️ Unknown notification authorization status")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +139,9 @@ final class NotificationService: NSObject {
|
||||||
/// Send a notification for a server event
|
/// Send a notification for a server event
|
||||||
/// - Parameter event: The server event to create a notification for
|
/// - Parameter event: The server event to create a notification for
|
||||||
func sendNotification(for event: ServerEvent) async {
|
func sendNotification(for event: ServerEvent) async {
|
||||||
|
// Check master switch first
|
||||||
|
guard configManager.notificationsEnabled else { return }
|
||||||
|
|
||||||
// Check preferences based on event type
|
// Check preferences based on event type
|
||||||
switch event.type {
|
switch event.type {
|
||||||
case .sessionStart:
|
case .sessionStart:
|
||||||
|
|
@ -228,6 +234,76 @@ 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 }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Session Started"
|
||||||
|
content.body = sessionName
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "SESSION"
|
||||||
|
content.interruptionLevel = .passive
|
||||||
|
|
||||||
|
deliverNotificationWithAutoDismiss(content, identifier: "session-start-\(UUID().uuidString)", dismissAfter: 5.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a session exit notification (legacy method for compatibility)
|
||||||
|
func sendSessionExitNotification(sessionName: String, exitCode: Int) async {
|
||||||
|
guard configManager.notificationsEnabled && preferences.sessionExit else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Session Ended"
|
||||||
|
content.body = sessionName
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "SESSION"
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
content.subtitle = "Exit code: \(exitCode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "session-exit-\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a command completion notification (legacy method for compatibility)
|
||||||
|
func sendCommandCompletionNotification(command: String, duration: Int) async {
|
||||||
|
guard configManager.notificationsEnabled && preferences.commandCompletion else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Your Turn"
|
||||||
|
content.body = command
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "COMMAND"
|
||||||
|
content.interruptionLevel = .active
|
||||||
|
|
||||||
|
// Format duration if provided
|
||||||
|
if duration > 0 {
|
||||||
|
let seconds = duration / 1000
|
||||||
|
if seconds < 60 {
|
||||||
|
content.subtitle = "\(seconds)s"
|
||||||
|
} else {
|
||||||
|
let minutes = seconds / 60
|
||||||
|
let remainingSeconds = seconds % 60
|
||||||
|
content.subtitle = "\(minutes)m \(remainingSeconds)s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "command-\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a generic notification
|
||||||
|
func sendGenericNotification(title: String, body: String) async {
|
||||||
|
guard configManager.notificationsEnabled else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "GENERAL"
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "generic-\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
/// Open System Settings to the Notifications pane
|
/// Open System Settings to the Notifications pane
|
||||||
func openNotificationSettings() {
|
func openNotificationSettings() {
|
||||||
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
|
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
|
||||||
|
|
@ -326,73 +402,54 @@ final class NotificationService: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connect() {
|
private func connect() {
|
||||||
guard serverManager.isRunning, !isConnected else {
|
guard !isConnected else {
|
||||||
logger.debug("🔔 Server not running or already connected to event stream")
|
logger.info("Already connected to notification service")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = serverManager.port
|
guard let authToken = serverManager.localAuthToken else {
|
||||||
guard let url = URL(string: "http://localhost:\(port)/api/events") else {
|
logger.error("No auth token available for notification service")
|
||||||
logger.error("🔴 Invalid event stream URL for port \(port)")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("🔔 Connecting to server event stream at \(url.absoluteString)")
|
guard let url = URL(string: "http://localhost:\(serverManager.port)/events") else {
|
||||||
|
logger.error("Invalid events URL")
|
||||||
eventSource = EventSource(url: url)
|
return
|
||||||
|
|
||||||
// Add authentication if available
|
|
||||||
if let localToken = serverManager.bunServer?.localToken {
|
|
||||||
eventSource?.addHeader("X-VibeTunnel-Local", value: localToken)
|
|
||||||
logger.debug("🔐 Added local auth token to event stream")
|
|
||||||
} else {
|
|
||||||
logger.warning("⚠️ No local auth token available for event stream")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create headers
|
||||||
|
var headers: [String: String] = [
|
||||||
|
"Authorization": "Bearer \(authToken)",
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add custom header to indicate this is the Mac app
|
||||||
|
headers["X-VibeTunnel-Client"] = "mac-app"
|
||||||
|
|
||||||
|
eventSource = EventSource(url: url, headers: headers)
|
||||||
|
|
||||||
eventSource?.onOpen = { [weak self] in
|
eventSource?.onOpen = { [weak self] in
|
||||||
self?.logger.info("✅ Event stream connected successfully")
|
Task { @MainActor in
|
||||||
self?.isConnected = true
|
self?.logger.info("✅ Connected to notification event stream")
|
||||||
|
self?.isConnected = true
|
||||||
// Send synthetic events for existing sessions
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
// Get current sessions from SessionMonitor
|
|
||||||
let sessions = await SessionMonitor.shared.getSessions()
|
|
||||||
|
|
||||||
for (sessionId, session) in sessions where session.isRunning {
|
|
||||||
let sessionName = session.name
|
|
||||||
self.logger.info("📨 Sending synthetic session-start event for existing session: \(sessionId)")
|
|
||||||
|
|
||||||
// Create synthetic ServerEvent
|
|
||||||
let syntheticEvent = ServerEvent.sessionStart(
|
|
||||||
sessionId: sessionId,
|
|
||||||
sessionName: sessionName,
|
|
||||||
command: session.command.joined(separator: " ")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle as if it was a real event
|
|
||||||
self.handleSessionStart(syntheticEvent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource?.onError = { [weak self] error in
|
eventSource?.onError = { [weak self] error in
|
||||||
self?.logger.error("🔴 Event stream error: \(error?.localizedDescription ?? "Unknown")")
|
Task { @MainActor in
|
||||||
self?.isConnected = false
|
if let error = error {
|
||||||
|
self?.logger.error("❌ EventSource error: \(error)")
|
||||||
// Schedule reconnection after delay
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
|
|
||||||
if let self, !self.isConnected && self.serverManager.isRunning {
|
|
||||||
self.logger.info("🔄 Attempting to reconnect event stream...")
|
|
||||||
self.connect()
|
|
||||||
}
|
}
|
||||||
|
self?.isConnected = false
|
||||||
|
// Don't reconnect here - let server state changes trigger reconnection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource?.onMessage = { [weak self] event in
|
eventSource?.onMessage = { [weak self] event in
|
||||||
self?.handleServerEvent(event)
|
Task { @MainActor in
|
||||||
|
self?.handleEvent(event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource?.connect()
|
eventSource?.connect()
|
||||||
|
|
@ -402,280 +459,283 @@ final class NotificationService: NSObject {
|
||||||
eventSource?.disconnect()
|
eventSource?.disconnect()
|
||||||
eventSource = nil
|
eventSource = nil
|
||||||
isConnected = false
|
isConnected = false
|
||||||
logger.info("Disconnected from event stream")
|
logger.info("Disconnected from notification service")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleServerEvent(_ event: EventSource.Event) {
|
private func handleEvent(_ event: Event) {
|
||||||
guard let data = event.data else {
|
guard let data = event.data else { return }
|
||||||
logger.debug("🔔 Received event with no data")
|
|
||||||
return
|
logger.debug("📨 Received event: \(data)")
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
guard let jsonData = data.data(using: .utf8) else {
|
guard let jsonData = data.data(using: .utf8) else {
|
||||||
logger.error("🔴 Failed to convert event data to UTF-8")
|
logger.error("Failed to convert event data to UTF-8")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode the JSON into a dictionary
|
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] ?? [:]
|
||||||
guard let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
|
||||||
let typeString = json["type"] as? String,
|
guard let type = json["type"] as? String else {
|
||||||
let eventType = ServerEventType(rawValue: typeString) else {
|
logger.error("Event missing type field")
|
||||||
logger.error("🔴 Invalid event type or format: \(data)")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ServerEvent from the JSON data
|
// Process based on event type and user preferences
|
||||||
let decoder = JSONDecoder()
|
switch type {
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
case "session-start":
|
||||||
|
logger.info("🚀 Processing session-start event")
|
||||||
// Map the JSON to ServerEvent structure
|
if configManager.notificationsEnabled && preferences.sessionStart {
|
||||||
var serverEvent = ServerEvent(
|
handleSessionStart(json)
|
||||||
type: eventType,
|
} else {
|
||||||
sessionId: json["sessionId"] as? String,
|
logger.debug("Session start notifications disabled")
|
||||||
sessionName: json["sessionName"] as? String,
|
|
||||||
command: json["command"] as? String,
|
|
||||||
exitCode: json["exitCode"] as? Int,
|
|
||||||
duration: json["duration"] as? Int,
|
|
||||||
message: json["message"] as? String,
|
|
||||||
timestamp: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse timestamp if available
|
|
||||||
if let timestampString = json["timestamp"] as? String,
|
|
||||||
let timestampData = timestampString.data(using: .utf8),
|
|
||||||
let timestamp = try? decoder.decode(Date.self, from: timestampData) {
|
|
||||||
serverEvent = ServerEvent(
|
|
||||||
type: eventType,
|
|
||||||
sessionId: serverEvent.sessionId,
|
|
||||||
sessionName: serverEvent.sessionName,
|
|
||||||
command: serverEvent.command,
|
|
||||||
exitCode: serverEvent.exitCode,
|
|
||||||
duration: serverEvent.duration,
|
|
||||||
message: serverEvent.message,
|
|
||||||
timestamp: timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("📨 Received event: \(serverEvent.type.rawValue)")
|
|
||||||
|
|
||||||
// Special handling for session start events
|
|
||||||
if serverEvent.type == .sessionStart {
|
|
||||||
handleSessionStart(serverEvent)
|
|
||||||
} else if serverEvent.type == .connected {
|
|
||||||
logger.debug("📡 Connected event received")
|
|
||||||
} else {
|
|
||||||
// Send notification for all other event types
|
|
||||||
Task {
|
|
||||||
await sendNotification(for: serverEvent)
|
|
||||||
}
|
}
|
||||||
|
case "session-exit":
|
||||||
|
logger.info("🏁 Processing session-exit event")
|
||||||
|
if configManager.notificationsEnabled && 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 {
|
||||||
|
handleCommandFinished(json)
|
||||||
|
} else {
|
||||||
|
logger.debug("Command completion notifications disabled")
|
||||||
|
}
|
||||||
|
case "command-error":
|
||||||
|
logger.info("❌ Processing command-error event")
|
||||||
|
if configManager.notificationsEnabled && preferences.commandError {
|
||||||
|
handleCommandError(json)
|
||||||
|
} else {
|
||||||
|
logger.debug("Command error notifications disabled")
|
||||||
|
}
|
||||||
|
case "bell":
|
||||||
|
logger.info("🔔 Processing bell event")
|
||||||
|
if configManager.notificationsEnabled && preferences.bell {
|
||||||
|
handleBell(json)
|
||||||
|
} else {
|
||||||
|
logger.debug("Bell notifications disabled")
|
||||||
|
}
|
||||||
|
case "claude-turn":
|
||||||
|
logger.info("💬 Processing claude-turn event")
|
||||||
|
if configManager.notificationsEnabled && preferences.claudeTurn {
|
||||||
|
handleClaudeTurn(json)
|
||||||
|
} else {
|
||||||
|
logger.debug("Claude turn notifications disabled")
|
||||||
|
}
|
||||||
|
case "connected":
|
||||||
|
logger.info("🔗 Received connected event from server")
|
||||||
|
// No notification for connected events
|
||||||
|
default:
|
||||||
|
logger.warning("Unknown event type: \(type)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("🔴 Failed to parse event: \(error)")
|
logger.error("Failed to parse event data: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSessionStart(_ event: ServerEvent) {
|
// MARK: - Event Handlers
|
||||||
// Check for duplicate notifications
|
|
||||||
if let sessionId = event.sessionId {
|
|
||||||
if recentlyNotifiedSessions.contains(sessionId) {
|
|
||||||
logger.debug("Skipping duplicate notification for session \(sessionId)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recentlyNotifiedSessions.insert(sessionId)
|
|
||||||
|
|
||||||
// Schedule cleanup after 10 seconds
|
private func handleSessionStart(_ json: [String: Any]) {
|
||||||
Task { @MainActor in
|
guard let sessionId = json["sessionId"] as? String else {
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds
|
logger.error("Session start event missing sessionId")
|
||||||
self.recentlyNotifiedSessions.remove(sessionId)
|
return
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the consolidated notification method
|
let sessionName = json["sessionName"] as? String ?? "Terminal Session"
|
||||||
Task {
|
|
||||||
await sendNotification(for: event)
|
// Prevent duplicate notifications
|
||||||
|
if recentlyNotifiedSessions.contains("start-\(sessionId)") {
|
||||||
|
logger.debug("Skipping duplicate session start notification for \(sessionId)")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recentlyNotifiedSessions.insert("start-\(sessionId)")
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Session Started"
|
||||||
|
content.body = sessionName
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "SESSION"
|
||||||
|
content.userInfo = ["sessionId": sessionId, "type": "session-start"]
|
||||||
|
content.interruptionLevel = .passive
|
||||||
|
|
||||||
|
deliverNotificationWithAutoDismiss(content, identifier: "session-start-\(sessionId)", dismissAfter: 5.0)
|
||||||
|
|
||||||
|
// Schedule cleanup
|
||||||
|
scheduleNotificationCleanup(for: "start-\(sessionId)", after: 30)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deliverNotification(_ content: UNMutableNotificationContent, identifier: String) {
|
private func handleSessionExit(_ json: [String: Any]) {
|
||||||
|
guard let sessionId = json["sessionId"] as? String else {
|
||||||
|
logger.error("Session exit event missing sessionId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionName = json["sessionName"] as? String ?? "Terminal Session"
|
||||||
|
let exitCode = json["exitCode"] as? Int ?? 0
|
||||||
|
|
||||||
|
// Prevent duplicate notifications
|
||||||
|
if recentlyNotifiedSessions.contains("exit-\(sessionId)") {
|
||||||
|
logger.debug("Skipping duplicate session exit notification for \(sessionId)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recentlyNotifiedSessions.insert("exit-\(sessionId)")
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Session Ended"
|
||||||
|
content.body = sessionName
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "SESSION"
|
||||||
|
content.userInfo = ["sessionId": sessionId, "type": "session-exit", "exitCode": exitCode]
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
content.subtitle = "Exit code: \(exitCode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "session-exit-\(sessionId)")
|
||||||
|
|
||||||
|
// Schedule cleanup
|
||||||
|
scheduleNotificationCleanup(for: "exit-\(sessionId)", after: 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCommandFinished(_ json: [String: Any]) {
|
||||||
|
let command = json["command"] as? String ?? "Command"
|
||||||
|
let duration = json["duration"] as? Int ?? 0
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Your Turn"
|
||||||
|
content.body = command
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "COMMAND"
|
||||||
|
content.interruptionLevel = .active
|
||||||
|
|
||||||
|
// Format duration if provided
|
||||||
|
if duration > 0 {
|
||||||
|
let seconds = duration / 1000
|
||||||
|
if seconds < 60 {
|
||||||
|
content.subtitle = "\(seconds)s"
|
||||||
|
} else {
|
||||||
|
let minutes = seconds / 60
|
||||||
|
let remainingSeconds = seconds % 60
|
||||||
|
content.subtitle = "\(minutes)m \(remainingSeconds)s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sessionId = json["sessionId"] as? String {
|
||||||
|
content.userInfo = ["sessionId": sessionId, "type": "command-finished"]
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "command-\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCommandError(_ json: [String: Any]) {
|
||||||
|
let command = json["command"] as? String ?? "Command"
|
||||||
|
let exitCode = json["exitCode"] as? Int ?? 1
|
||||||
|
let duration = json["duration"] as? Int
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Command Failed"
|
||||||
|
content.body = command
|
||||||
|
content.sound = getNotificationSound(critical: true)
|
||||||
|
content.categoryIdentifier = "COMMAND"
|
||||||
|
content.subtitle = "Exit code: \(exitCode)"
|
||||||
|
|
||||||
|
if let sessionId = json["sessionId"] as? String {
|
||||||
|
content.userInfo = ["sessionId": sessionId, "type": "command-error", "exitCode": exitCode]
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "error-\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBell(_ json: [String: Any]) {
|
||||||
|
guard let sessionId = json["sessionId"] as? String else {
|
||||||
|
logger.error("Bell event missing sessionId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionName = json["sessionName"] as? String ?? "Terminal"
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Terminal Bell"
|
||||||
|
content.body = sessionName
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "BELL"
|
||||||
|
content.userInfo = ["sessionId": sessionId, "type": "bell"]
|
||||||
|
|
||||||
|
if let message = json["message"] as? String {
|
||||||
|
content.subtitle = message
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "bell-\(sessionId)-\(Date().timeIntervalSince1970)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleClaudeTurn(_ json: [String: Any]) {
|
||||||
|
guard let sessionId = json["sessionId"] as? String else {
|
||||||
|
logger.error("Claude turn event missing sessionId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionName = json["sessionName"] as? String ?? "Claude"
|
||||||
|
let message = json["message"] as? String ?? "Claude has finished responding"
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Your Turn"
|
||||||
|
content.body = message
|
||||||
|
content.subtitle = sessionName
|
||||||
|
content.sound = getNotificationSound()
|
||||||
|
content.categoryIdentifier = "CLAUDE_TURN"
|
||||||
|
content.userInfo = ["sessionId": sessionId, "type": "claude-turn"]
|
||||||
|
content.interruptionLevel = .active
|
||||||
|
|
||||||
|
deliverNotification(content, identifier: "claude-turn-\(sessionId)-\(Date().timeIntervalSince1970)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Delivery
|
||||||
|
|
||||||
|
private func deliverNotification(_ content: UNNotificationContent, identifier: String) {
|
||||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
|
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
|
||||||
|
|
||||||
Task {
|
UNUserNotificationCenter.current().add(request) { [weak self] error in
|
||||||
do {
|
if let error = error {
|
||||||
try await UNUserNotificationCenter.current().add(request)
|
self?.logger.error("Failed to deliver notification: \(error)")
|
||||||
logger.info("🔔 Delivered notification: '\(content.title)' - '\(content.body)'")
|
} else {
|
||||||
} catch {
|
self?.logger.debug("Notification delivered: \(identifier)")
|
||||||
logger.error("🔴 Failed to deliver notification '\(content.title)': \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deliverNotificationWithAutoDismiss(
|
private func deliverNotificationWithAutoDismiss(
|
||||||
_ content: UNMutableNotificationContent,
|
_ content: UNNotificationContent,
|
||||||
identifier: String,
|
identifier: String,
|
||||||
dismissAfter seconds: Double
|
dismissAfter seconds: TimeInterval
|
||||||
) {
|
) {
|
||||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
|
deliverNotification(content, identifier: identifier)
|
||||||
|
|
||||||
Task {
|
// Schedule automatic dismissal
|
||||||
do {
|
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
|
||||||
try await UNUserNotificationCenter.current().add(request)
|
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier])
|
||||||
logger
|
|
||||||
.info(
|
|
||||||
"🔔 Delivered auto-dismiss notification: '\(content.title)' - '\(content.body)' (dismiss in \(seconds)s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schedule automatic dismissal
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
|
||||||
|
|
||||||
// Remove the notification
|
|
||||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier])
|
|
||||||
logger.debug("🔔 Auto-dismissed notification: \(identifier)")
|
|
||||||
} catch {
|
|
||||||
logger.error("🔴 Failed to deliver auto-dismiss notification '\(content.title)': \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
private func scheduleNotificationCleanup(for key: String, after seconds: TimeInterval) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
|
||||||
|
self?.recentlyNotifiedSessions.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
disconnect()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - EventSource
|
// MARK: - Extensions
|
||||||
|
|
||||||
/// A lightweight Server-Sent Events (SSE) client for receiving real-time notifications.
|
|
||||||
///
|
|
||||||
/// `EventSource` establishes a persistent HTTP connection to receive server-sent events
|
|
||||||
/// from the VibeTunnel server. It handles connection management, event parsing, and
|
|
||||||
/// automatic reconnection on failure.
|
|
||||||
///
|
|
||||||
/// - Note: This is a private implementation detail of `NotificationService`.
|
|
||||||
private final class EventSource: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
|
||||||
private let url: URL
|
|
||||||
private var session: URLSession?
|
|
||||||
private var task: URLSessionDataTask?
|
|
||||||
private var headers: [String: String] = [:]
|
|
||||||
|
|
||||||
var onOpen: (() -> Void)?
|
|
||||||
var onMessage: ((Event) -> Void)?
|
|
||||||
var onError: ((Error?) -> Void)?
|
|
||||||
|
|
||||||
/// Represents a single Server-Sent Event.
|
|
||||||
struct Event {
|
|
||||||
/// Optional event identifier.
|
|
||||||
let id: String?
|
|
||||||
/// Optional event type.
|
|
||||||
let event: String?
|
|
||||||
/// The event data payload.
|
|
||||||
let data: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
init(url: URL) {
|
|
||||||
self.url = url
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func addHeader(_ name: String, value: String) {
|
|
||||||
headers[name] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() {
|
|
||||||
let configuration = URLSessionConfiguration.default
|
|
||||||
configuration.timeoutIntervalForRequest = TimeInterval.infinity
|
|
||||||
configuration.timeoutIntervalForResource = TimeInterval.infinity
|
|
||||||
|
|
||||||
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
|
||||||
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
|
||||||
|
|
||||||
// Add custom headers
|
|
||||||
for (name, value) in headers {
|
|
||||||
request.setValue(value, forHTTPHeaderField: name)
|
|
||||||
}
|
|
||||||
|
|
||||||
task = session?.dataTask(with: request)
|
|
||||||
task?.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() {
|
|
||||||
task?.cancel()
|
|
||||||
session?.invalidateAndCancel()
|
|
||||||
task = nil
|
|
||||||
session = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// URLSessionDataDelegate
|
|
||||||
|
|
||||||
nonisolated func urlSession(
|
|
||||||
_ session: URLSession,
|
|
||||||
dataTask: URLSessionDataTask,
|
|
||||||
didReceive response: URLResponse,
|
|
||||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
|
||||||
) {
|
|
||||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onOpen?()
|
|
||||||
}
|
|
||||||
completionHandler(.allow)
|
|
||||||
} else {
|
|
||||||
completionHandler(.cancel)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onError?(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buffer = ""
|
|
||||||
|
|
||||||
nonisolated func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
||||||
guard let text = String(data: data, encoding: .utf8) else { return }
|
|
||||||
buffer += text
|
|
||||||
|
|
||||||
// Process complete events
|
|
||||||
let lines = buffer.components(separatedBy: "\n")
|
|
||||||
buffer = lines.last ?? ""
|
|
||||||
|
|
||||||
var currentEvent = Event(id: nil, event: nil, data: nil)
|
|
||||||
var dataLines: [String] = []
|
|
||||||
|
|
||||||
for line in lines.dropLast() {
|
|
||||||
if line.isEmpty {
|
|
||||||
// End of event
|
|
||||||
if !dataLines.isEmpty {
|
|
||||||
let data = dataLines.joined(separator: "\n")
|
|
||||||
let event = Event(id: currentEvent.id, event: currentEvent.event, data: data)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onMessage?(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentEvent = Event(id: nil, event: nil, data: nil)
|
|
||||||
dataLines = []
|
|
||||||
} else if line.hasPrefix("id:") {
|
|
||||||
currentEvent = Event(
|
|
||||||
id: line.dropFirst(3).trimmingCharacters(in: .whitespaces),
|
|
||||||
event: currentEvent.event,
|
|
||||||
data: currentEvent.data
|
|
||||||
)
|
|
||||||
} else if line.hasPrefix("event:") {
|
|
||||||
currentEvent = Event(
|
|
||||||
id: currentEvent.id,
|
|
||||||
event: line.dropFirst(6).trimmingCharacters(in: .whitespaces),
|
|
||||||
data: currentEvent.data
|
|
||||||
)
|
|
||||||
} else if line.hasPrefix("data:") {
|
|
||||||
dataLines.append(String(line.dropFirst(5).trimmingCharacters(in: .whitespaces)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onError?(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Names
|
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let serverStateChanged = Notification.Name("serverStateChanged")
|
static let serverStateChanged = Notification.Name("ServerStateChanged")
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +164,7 @@ final class SessionMonitor {
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
|
|
||||||
// Notify for sessions that have just ended
|
// Notify for sessions that have just ended
|
||||||
if firstFetchDone && UserDefaults.standard.bool(forKey: "showNotifications") {
|
if firstFetchDone && ConfigManager.shared.notificationsEnabled {
|
||||||
let ended = Self.detectEndedSessions(from: oldSessions, to: sessionsDict)
|
let ended = Self.detectEndedSessions(from: oldSessions, to: sessionsDict)
|
||||||
for session in ended {
|
for session in ended {
|
||||||
let id = session.id
|
let id = session.id
|
||||||
|
|
@ -286,8 +286,8 @@ final class SessionMonitor {
|
||||||
async
|
async
|
||||||
{
|
{
|
||||||
// Check if Claude notifications are enabled using ConfigManager
|
// Check if Claude notifications are enabled using ConfigManager
|
||||||
let claudeNotificationsEnabled = ConfigManager.shared.notificationClaudeTurn
|
// Must check both master switch and specific preference
|
||||||
guard claudeNotificationsEnabled else { return }
|
guard ConfigManager.shared.notificationsEnabled && ConfigManager.shared.notificationClaudeTurn else { return }
|
||||||
|
|
||||||
for (id, newSession) in new {
|
for (id, newSession) in new {
|
||||||
// Only process running sessions
|
// Only process running sessions
|
||||||
|
|
|
||||||
|
|
@ -4,68 +4,94 @@ import UserNotifications
|
||||||
|
|
||||||
@Suite("NotificationService Tests")
|
@Suite("NotificationService Tests")
|
||||||
struct NotificationServiceTests {
|
struct NotificationServiceTests {
|
||||||
@Test("Default notification preferences are loaded correctly")
|
@Test("Notification preferences are loaded correctly from ConfigManager")
|
||||||
@MainActor
|
@MainActor
|
||||||
func defaultPreferences() {
|
func loadPreferencesFromConfig() {
|
||||||
// Clear UserDefaults to simulate fresh install
|
// This test verifies that NotificationPreferences correctly loads values from ConfigManager
|
||||||
let defaults = UserDefaults.standard
|
let configManager = ConfigManager.shared
|
||||||
defaults.removeObject(forKey: "notifications.initialized")
|
let preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
defaults.removeObject(forKey: "notifications.sessionStart")
|
|
||||||
defaults.removeObject(forKey: "notifications.sessionExit")
|
|
||||||
defaults.removeObject(forKey: "notifications.commandCompletion")
|
|
||||||
defaults.removeObject(forKey: "notifications.commandError")
|
|
||||||
defaults.removeObject(forKey: "notifications.bell")
|
|
||||||
defaults.removeObject(forKey: "notifications.claudeTurn")
|
|
||||||
defaults.synchronize() // Force synchronization after removal
|
|
||||||
|
|
||||||
// Create preferences - this should trigger default initialization
|
// Verify that preferences match ConfigManager values
|
||||||
let preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
#expect(preferences.sessionStart == configManager.notificationSessionStart)
|
||||||
|
#expect(preferences.sessionExit == configManager.notificationSessionExit)
|
||||||
|
#expect(preferences.commandCompletion == configManager.notificationCommandCompletion)
|
||||||
|
#expect(preferences.commandError == configManager.notificationCommandError)
|
||||||
|
#expect(preferences.bell == configManager.notificationBell)
|
||||||
|
#expect(preferences.claudeTurn == configManager.notificationClaudeTurn)
|
||||||
|
#expect(preferences.soundEnabled == configManager.notificationSoundEnabled)
|
||||||
|
#expect(preferences.vibrationEnabled == configManager.notificationVibrationEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove debug prints
|
@Test("Default notification values match expected defaults")
|
||||||
|
@MainActor
|
||||||
|
func verifyDefaultValues() {
|
||||||
|
// This test documents what the default values SHOULD be
|
||||||
|
// In production, these would be set when no config file exists
|
||||||
|
|
||||||
// Verify default values are properly loaded
|
// Expected defaults based on TypeScript config:
|
||||||
#expect(preferences.sessionStart == true)
|
// - Master switch (notificationsEnabled) should be false
|
||||||
#expect(preferences.sessionExit == true)
|
// - Individual preferences should be true (except claudeTurn)
|
||||||
#expect(preferences.commandCompletion == true)
|
// - Sound and vibration should be enabled
|
||||||
#expect(preferences.commandError == true)
|
|
||||||
#expect(preferences.bell == true)
|
|
||||||
#expect(preferences.claudeTurn == false)
|
|
||||||
|
|
||||||
// Verify UserDefaults was also set correctly
|
// Note: In actual tests, ConfigManager loads from ~/.vibetunnel/config.json
|
||||||
#expect(defaults.bool(forKey: "notifications.sessionStart") == true)
|
// To test true defaults, we would need to:
|
||||||
#expect(defaults.bool(forKey: "notifications.sessionExit") == true)
|
// 1. Mock ConfigManager
|
||||||
#expect(defaults.bool(forKey: "notifications.commandCompletion") == true)
|
// 2. Clear the config file
|
||||||
#expect(defaults.bool(forKey: "notifications.commandError") == true)
|
// 3. Force ConfigManager to use defaults
|
||||||
#expect(defaults.bool(forKey: "notifications.bell") == true)
|
|
||||||
#expect(defaults.bool(forKey: "notifications.claudeTurn") == false)
|
// For now, we document the expected behavior
|
||||||
#expect(defaults.bool(forKey: "notifications.initialized") == true)
|
let expectedMasterSwitch = false
|
||||||
|
let expectedSessionStart = true
|
||||||
|
let expectedSessionExit = true
|
||||||
|
let expectedCommandCompletion = true
|
||||||
|
let expectedCommandError = true
|
||||||
|
let expectedBell = true
|
||||||
|
let expectedClaudeTurn = false
|
||||||
|
let expectedSound = true
|
||||||
|
let expectedVibration = true
|
||||||
|
|
||||||
|
// These are the values that SHOULD be used when no config exists
|
||||||
|
#expect(expectedMasterSwitch == false, "Master switch should be OFF by default")
|
||||||
|
#expect(expectedSessionStart == true, "Session start should be enabled by default")
|
||||||
|
#expect(expectedSessionExit == true, "Session exit should be enabled by default")
|
||||||
|
#expect(expectedCommandCompletion == true, "Command completion should be enabled by default")
|
||||||
|
#expect(expectedCommandError == true, "Command error should be enabled by default")
|
||||||
|
#expect(expectedBell == true, "Bell should be enabled by default")
|
||||||
|
#expect(expectedClaudeTurn == false, "Claude turn should be disabled by default")
|
||||||
|
#expect(expectedSound == true, "Sound should be enabled by default")
|
||||||
|
#expect(expectedVibration == true, "Vibration should be enabled by default")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Notification preferences can be updated")
|
@Test("Notification preferences can be updated")
|
||||||
@MainActor
|
@MainActor
|
||||||
func testUpdatePreferences() {
|
func testUpdatePreferences() {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
// Create custom preferences
|
// Create custom preferences
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.sessionStart = false
|
preferences.sessionStart = true
|
||||||
preferences.bell = false
|
preferences.bell = true
|
||||||
|
|
||||||
// Update preferences
|
// Update preferences
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
// Verify preferences were updated in UserDefaults
|
// Verify preferences were updated in ConfigManager
|
||||||
#expect(UserDefaults.standard.bool(forKey: "notifications.sessionStart") == false)
|
#expect(configManager.notificationSessionStart == true)
|
||||||
#expect(UserDefaults.standard.bool(forKey: "notifications.bell") == false)
|
#expect(configManager.notificationBell == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Session start notification is sent when enabled")
|
@Test("Session start notification is sent when enabled")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sessionStartNotification() async throws {
|
func sessionStartNotification() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
|
// Enable notifications master switch
|
||||||
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
// Enable session start notifications
|
// Enable session start notifications
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.sessionStart = true
|
preferences.sessionStart = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
|
@ -75,7 +101,7 @@ struct NotificationServiceTests {
|
||||||
|
|
||||||
// Verify notification would be created (actual delivery depends on system permissions)
|
// Verify notification would be created (actual delivery depends on system permissions)
|
||||||
// In a real test environment, we'd mock UNUserNotificationCenter
|
// In a real test environment, we'd mock UNUserNotificationCenter
|
||||||
// Note: NotificationService doesn't expose an isEnabled property
|
#expect(configManager.notificationsEnabled == true)
|
||||||
#expect(preferences.sessionStart == true)
|
#expect(preferences.sessionStart == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,9 +109,13 @@ struct NotificationServiceTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
func sessionExitNotification() async throws {
|
func sessionExitNotification() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
|
// Enable notifications master switch
|
||||||
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
// Enable session exit notifications
|
// Enable session exit notifications
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.sessionExit = true
|
preferences.sessionExit = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
|
@ -95,6 +125,7 @@ struct NotificationServiceTests {
|
||||||
// Test error exit
|
// Test error exit
|
||||||
await service.sendSessionExitNotification(sessionName: "Failed Session", exitCode: 1)
|
await service.sendSessionExitNotification(sessionName: "Failed Session", exitCode: 1)
|
||||||
|
|
||||||
|
#expect(configManager.notificationsEnabled == true)
|
||||||
#expect(preferences.sessionExit == true)
|
#expect(preferences.sessionExit == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,9 +133,13 @@ struct NotificationServiceTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
func commandCompletionNotification() async throws {
|
func commandCompletionNotification() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
|
// Enable notifications master switch
|
||||||
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
// Enable command completion notifications
|
// Enable command completion notifications
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.commandCompletion = true
|
preferences.commandCompletion = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
|
@ -120,6 +155,7 @@ struct NotificationServiceTests {
|
||||||
duration: 5_000 // 5 seconds
|
duration: 5_000 // 5 seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#expect(configManager.notificationsEnabled == true)
|
||||||
#expect(preferences.commandCompletion == true)
|
#expect(preferences.commandCompletion == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,9 +163,13 @@ struct NotificationServiceTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
func commandErrorNotification() async throws {
|
func commandErrorNotification() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
|
// Enable notifications master switch
|
||||||
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
// Enable command error notifications
|
// Enable command error notifications
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.commandError = true
|
preferences.commandError = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
|
@ -141,6 +181,7 @@ struct NotificationServiceTests {
|
||||||
duration: 1_000
|
duration: 1_000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#expect(configManager.notificationsEnabled == true)
|
||||||
#expect(preferences.commandError == true)
|
#expect(preferences.commandError == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,9 +189,13 @@ struct NotificationServiceTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
func bellNotification() async throws {
|
func bellNotification() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
|
// Enable notifications master switch
|
||||||
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
// Enable bell notifications
|
// Enable bell notifications
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.bell = true
|
preferences.bell = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
|
@ -158,6 +203,7 @@ struct NotificationServiceTests {
|
||||||
// Note: Bell notifications are handled through the event stream
|
// Note: Bell notifications are handled through the event stream
|
||||||
await service.sendGenericNotification(title: "Terminal Bell", body: "Test Session")
|
await service.sendGenericNotification(title: "Terminal Bell", body: "Test Session")
|
||||||
|
|
||||||
|
#expect(configManager.notificationsEnabled == true)
|
||||||
#expect(preferences.bell == true)
|
#expect(preferences.bell == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,14 +211,18 @@ struct NotificationServiceTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
func disabledNotifications() async throws {
|
func disabledNotifications() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
// Disable all notifications
|
// Test 1: Master switch disabled (default)
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
configManager.updateNotificationPreferences(enabled: false)
|
||||||
preferences.sessionStart = false
|
|
||||||
preferences.sessionExit = false
|
// Even with individual preferences enabled, nothing should fire
|
||||||
preferences.commandCompletion = false
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.commandError = false
|
preferences.sessionStart = true
|
||||||
preferences.bell = false
|
preferences.sessionExit = true
|
||||||
|
preferences.commandCompletion = true
|
||||||
|
preferences.commandError = true
|
||||||
|
preferences.bell = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
// Try to send various notifications
|
// Try to send various notifications
|
||||||
|
|
@ -184,7 +234,24 @@ struct NotificationServiceTests {
|
||||||
)
|
)
|
||||||
await service.sendGenericNotification(title: "Bell", body: "Test")
|
await service.sendGenericNotification(title: "Bell", body: "Test")
|
||||||
|
|
||||||
// All should be ignored due to preferences
|
// Master switch should block all notifications
|
||||||
|
#expect(configManager.notificationsEnabled == false)
|
||||||
|
|
||||||
|
// Test 2: Master switch enabled but individual preferences disabled
|
||||||
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
|
preferences.sessionStart = false
|
||||||
|
preferences.sessionExit = false
|
||||||
|
preferences.commandCompletion = false
|
||||||
|
preferences.commandError = false
|
||||||
|
preferences.bell = false
|
||||||
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
// Try to send notifications again
|
||||||
|
await service.sendSessionStartNotification(sessionName: "Test")
|
||||||
|
await service.sendSessionExitNotification(sessionName: "Test", exitCode: 0)
|
||||||
|
|
||||||
|
// Individual preferences should block notifications
|
||||||
#expect(preferences.sessionStart == false)
|
#expect(preferences.sessionStart == false)
|
||||||
#expect(preferences.sessionExit == false)
|
#expect(preferences.sessionExit == false)
|
||||||
#expect(preferences.commandCompletion == false)
|
#expect(preferences.commandCompletion == false)
|
||||||
|
|
@ -195,9 +262,12 @@ struct NotificationServiceTests {
|
||||||
@MainActor
|
@MainActor
|
||||||
func missingSessionNames() async throws {
|
func missingSessionNames() async throws {
|
||||||
let service = NotificationService.shared
|
let service = NotificationService.shared
|
||||||
|
let configManager = ConfigManager.shared
|
||||||
|
|
||||||
// Enable notifications
|
// Enable notifications
|
||||||
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
|
configManager.updateNotificationPreferences(enabled: true)
|
||||||
|
|
||||||
|
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
|
||||||
preferences.sessionExit = true
|
preferences.sessionExit = true
|
||||||
service.updatePreferences(preferences)
|
service.updatePreferences(preferences)
|
||||||
|
|
||||||
|
|
@ -205,6 +275,7 @@ struct NotificationServiceTests {
|
||||||
await service.sendSessionExitNotification(sessionName: "", exitCode: 0)
|
await service.sendSessionExitNotification(sessionName: "", exitCode: 0)
|
||||||
|
|
||||||
// Should handle gracefully
|
// Should handle gracefully
|
||||||
|
#expect(configManager.notificationsEnabled == true)
|
||||||
#expect(preferences.sessionExit == true)
|
#expect(preferences.sessionExit == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue