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:
Peter Steinberger 2025-07-27 15:31:39 +02:00
parent a5d43e8274
commit 69a3ff0714
4 changed files with 488 additions and 356 deletions

View file

@ -226,7 +226,8 @@ final class ConfigManager {
self.repositoryBasePath = FilePathConstants.defaultRepositoryBasePath
// 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.notificationSessionExit = true
self.notificationCommandCompletion = true

View file

@ -99,34 +99,37 @@ final class NotificationService: NSObject {
return true
} else {
logger.warning("❌ Notification permissions denied")
logger.warning("⚠️ Notification permissions denied by user")
return false
}
} catch {
logger.error("Failed to request notification permissions: \(error)")
logger.error("Failed to request notification permissions: \(error)")
return false
}
case .denied:
// Already denied - open System Settings
logger.info("Opening System Settings to Notifications pane")
openNotificationSettings()
logger.warning("⚠️ Notification permissions previously denied")
return false
case .authorized, .provisional, .ephemeral:
// Already authorized - show test notification
logger.info("✅ Notifications already authorized")
case .authorized, .provisional:
logger.info("✅ Notification permissions already granted")
// Show test notification
let content = UNMutableNotificationContent()
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()
deliverNotification(content, identifier: "permission-test-\(UUID().uuidString)")
return true
case .ephemeral:
logger.info(" Ephemeral notification permissions")
return true
@unknown default:
logger.warning("⚠️ Unknown notification authorization status")
return false
}
}
@ -136,6 +139,9 @@ final class NotificationService: NSObject {
/// Send a notification for a server event
/// - 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 }
// Check preferences based on event type
switch event.type {
case .sessionStart:
@ -154,7 +160,7 @@ final class NotificationService: NSObject {
// Connected events don't trigger notifications
return
}
let content = UNMutableNotificationContent()
// Configure notification based on event type
@ -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
func openNotificationSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
@ -326,73 +402,54 @@ final class NotificationService: NSObject {
}
private func connect() {
guard serverManager.isRunning, !isConnected else {
logger.debug("🔔 Server not running or already connected to event stream")
guard !isConnected else {
logger.info("Already connected to notification service")
return
}
let port = serverManager.port
guard let url = URL(string: "http://localhost:\(port)/api/events") else {
logger.error("🔴 Invalid event stream URL for port \(port)")
guard let authToken = serverManager.localAuthToken else {
logger.error("No auth token available for notification service")
return
}
logger.info("🔔 Connecting to server event stream at \(url.absoluteString)")
eventSource = EventSource(url: url)
// 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")
guard let url = URL(string: "http://localhost:\(serverManager.port)/events") else {
logger.error("Invalid events URL")
return
}
// 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
self?.logger.info("✅ Event stream connected successfully")
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)
}
Task { @MainActor in
self?.logger.info("✅ Connected to notification event stream")
self?.isConnected = true
}
}
eventSource?.onError = { [weak self] error in
self?.logger.error("🔴 Event stream error: \(error?.localizedDescription ?? "Unknown")")
self?.isConnected = false
// 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()
Task { @MainActor in
if let error = error {
self?.logger.error("❌ EventSource error: \(error)")
}
self?.isConnected = false
// Don't reconnect here - let server state changes trigger reconnection
}
}
eventSource?.onMessage = { [weak self] event in
self?.handleServerEvent(event)
Task { @MainActor in
self?.handleEvent(event)
}
}
eventSource?.connect()
@ -402,280 +459,283 @@ final class NotificationService: NSObject {
eventSource?.disconnect()
eventSource = nil
isConnected = false
logger.info("Disconnected from event stream")
logger.info("Disconnected from notification service")
}
private func handleServerEvent(_ event: EventSource.Event) {
guard let data = event.data else {
logger.debug("🔔 Received event with no data")
return
}
private func handleEvent(_ event: Event) {
guard let data = event.data else { return }
logger.debug("📨 Received event: \(data)")
do {
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
}
// Decode the JSON into a dictionary
guard let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let typeString = json["type"] as? String,
let eventType = ServerEventType(rawValue: typeString) else {
logger.error("🔴 Invalid event type or format: \(data)")
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] ?? [:]
guard let type = json["type"] as? String else {
logger.error("Event missing type field")
return
}
// Create ServerEvent from the JSON data
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
// Map the JSON to ServerEvent structure
var serverEvent = ServerEvent(
type: eventType,
sessionId: json["sessionId"] as? String,
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)
// Process based on event type and user preferences
switch type {
case "session-start":
logger.info("🚀 Processing session-start event")
if configManager.notificationsEnabled && 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 {
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 {
logger.error("🔴 Failed to parse event: \(error)")
logger.error("Failed to parse event data: \(error)")
}
}
private func handleSessionStart(_ event: ServerEvent) {
// 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)
// MARK: - Event Handlers
// Schedule cleanup after 10 seconds
Task { @MainActor in
try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds
self.recentlyNotifiedSessions.remove(sessionId)
}
private func handleSessionStart(_ json: [String: Any]) {
guard let sessionId = json["sessionId"] as? String else {
logger.error("Session start event missing sessionId")
return
}
// Use the consolidated notification method
Task {
await sendNotification(for: event)
let sessionName = json["sessionName"] as? String ?? "Terminal Session"
// 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)
Task {
do {
try await UNUserNotificationCenter.current().add(request)
logger.info("🔔 Delivered notification: '\(content.title)' - '\(content.body)'")
} catch {
logger.error("🔴 Failed to deliver notification '\(content.title)': \(error)")
UNUserNotificationCenter.current().add(request) { [weak self] error in
if let error = error {
self?.logger.error("Failed to deliver notification: \(error)")
} else {
self?.logger.debug("Notification delivered: \(identifier)")
}
}
}
private func deliverNotificationWithAutoDismiss(
_ content: UNMutableNotificationContent,
_ content: UNNotificationContent,
identifier: String,
dismissAfter seconds: Double
dismissAfter seconds: TimeInterval
) {
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
deliverNotification(content, identifier: identifier)
Task {
do {
try await UNUserNotificationCenter.current().add(request)
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)")
}
// Schedule automatic dismissal
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier])
}
}
// 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
/// 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
// MARK: - Extensions
extension Notification.Name {
static let serverStateChanged = Notification.Name("serverStateChanged")
}
static let serverStateChanged = Notification.Name("ServerStateChanged")
}

View file

@ -164,7 +164,7 @@ final class SessionMonitor {
self.lastError = nil
// 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)
for session in ended {
let id = session.id
@ -286,8 +286,8 @@ final class SessionMonitor {
async
{
// Check if Claude notifications are enabled using ConfigManager
let claudeNotificationsEnabled = ConfigManager.shared.notificationClaudeTurn
guard claudeNotificationsEnabled else { return }
// Must check both master switch and specific preference
guard ConfigManager.shared.notificationsEnabled && ConfigManager.shared.notificationClaudeTurn else { return }
for (id, newSession) in new {
// Only process running sessions

View file

@ -4,68 +4,94 @@ import UserNotifications
@Suite("NotificationService Tests")
struct NotificationServiceTests {
@Test("Default notification preferences are loaded correctly")
@Test("Notification preferences are loaded correctly from ConfigManager")
@MainActor
func defaultPreferences() {
// Clear UserDefaults to simulate fresh install
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "notifications.initialized")
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
func loadPreferencesFromConfig() {
// This test verifies that NotificationPreferences correctly loads values from ConfigManager
let configManager = ConfigManager.shared
let preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
// Create preferences - this should trigger default initialization
let preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
// Remove debug prints
// Verify default values are properly loaded
#expect(preferences.sessionStart == true)
#expect(preferences.sessionExit == true)
#expect(preferences.commandCompletion == true)
#expect(preferences.commandError == true)
#expect(preferences.bell == true)
#expect(preferences.claudeTurn == false)
// Verify UserDefaults was also set correctly
#expect(defaults.bool(forKey: "notifications.sessionStart") == true)
#expect(defaults.bool(forKey: "notifications.sessionExit") == true)
#expect(defaults.bool(forKey: "notifications.commandCompletion") == true)
#expect(defaults.bool(forKey: "notifications.commandError") == true)
#expect(defaults.bool(forKey: "notifications.bell") == true)
#expect(defaults.bool(forKey: "notifications.claudeTurn") == false)
#expect(defaults.bool(forKey: "notifications.initialized") == true)
// Verify that preferences match ConfigManager values
#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)
}
@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
// Expected defaults based on TypeScript config:
// - Master switch (notificationsEnabled) should be false
// - Individual preferences should be true (except claudeTurn)
// - Sound and vibration should be enabled
// Note: In actual tests, ConfigManager loads from ~/.vibetunnel/config.json
// To test true defaults, we would need to:
// 1. Mock ConfigManager
// 2. Clear the config file
// 3. Force ConfigManager to use defaults
// For now, we document the expected behavior
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")
@MainActor
func testUpdatePreferences() {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Create custom preferences
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
preferences.sessionStart = false
preferences.bell = false
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.sessionStart = true
preferences.bell = true
// Update preferences
service.updatePreferences(preferences)
// Verify preferences were updated in UserDefaults
#expect(UserDefaults.standard.bool(forKey: "notifications.sessionStart") == false)
#expect(UserDefaults.standard.bool(forKey: "notifications.bell") == false)
// Verify preferences were updated in ConfigManager
#expect(configManager.notificationSessionStart == true)
#expect(configManager.notificationBell == true)
}
@Test("Session start notification is sent when enabled")
@MainActor
func sessionStartNotification() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Enable notifications master switch
configManager.updateNotificationPreferences(enabled: true)
// Enable session start notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.sessionStart = true
service.updatePreferences(preferences)
@ -75,7 +101,7 @@ struct NotificationServiceTests {
// Verify notification would be created (actual delivery depends on system permissions)
// 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)
}
@ -83,9 +109,13 @@ struct NotificationServiceTests {
@MainActor
func sessionExitNotification() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Enable notifications master switch
configManager.updateNotificationPreferences(enabled: true)
// Enable session exit notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.sessionExit = true
service.updatePreferences(preferences)
@ -95,6 +125,7 @@ struct NotificationServiceTests {
// Test error exit
await service.sendSessionExitNotification(sessionName: "Failed Session", exitCode: 1)
#expect(configManager.notificationsEnabled == true)
#expect(preferences.sessionExit == true)
}
@ -102,9 +133,13 @@ struct NotificationServiceTests {
@MainActor
func commandCompletionNotification() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Enable notifications master switch
configManager.updateNotificationPreferences(enabled: true)
// Enable command completion notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.commandCompletion = true
service.updatePreferences(preferences)
@ -120,6 +155,7 @@ struct NotificationServiceTests {
duration: 5_000 // 5 seconds
)
#expect(configManager.notificationsEnabled == true)
#expect(preferences.commandCompletion == true)
}
@ -127,9 +163,13 @@ struct NotificationServiceTests {
@MainActor
func commandErrorNotification() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Enable notifications master switch
configManager.updateNotificationPreferences(enabled: true)
// Enable command error notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.commandError = true
service.updatePreferences(preferences)
@ -141,6 +181,7 @@ struct NotificationServiceTests {
duration: 1_000
)
#expect(configManager.notificationsEnabled == true)
#expect(preferences.commandError == true)
}
@ -148,9 +189,13 @@ struct NotificationServiceTests {
@MainActor
func bellNotification() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Enable notifications master switch
configManager.updateNotificationPreferences(enabled: true)
// Enable bell notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.bell = true
service.updatePreferences(preferences)
@ -158,6 +203,7 @@ struct NotificationServiceTests {
// Note: Bell notifications are handled through the event stream
await service.sendGenericNotification(title: "Terminal Bell", body: "Test Session")
#expect(configManager.notificationsEnabled == true)
#expect(preferences.bell == true)
}
@ -165,14 +211,18 @@ struct NotificationServiceTests {
@MainActor
func disabledNotifications() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Disable all notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
preferences.sessionStart = false
preferences.sessionExit = false
preferences.commandCompletion = false
preferences.commandError = false
preferences.bell = false
// Test 1: Master switch disabled (default)
configManager.updateNotificationPreferences(enabled: false)
// Even with individual preferences enabled, nothing should fire
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.sessionStart = true
preferences.sessionExit = true
preferences.commandCompletion = true
preferences.commandError = true
preferences.bell = true
service.updatePreferences(preferences)
// Try to send various notifications
@ -184,7 +234,24 @@ struct NotificationServiceTests {
)
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.sessionExit == false)
#expect(preferences.commandCompletion == false)
@ -195,9 +262,12 @@ struct NotificationServiceTests {
@MainActor
func missingSessionNames() async throws {
let service = NotificationService.shared
let configManager = ConfigManager.shared
// Enable notifications
var preferences = NotificationService.NotificationPreferences(fromConfig: ConfigManager.shared)
configManager.updateNotificationPreferences(enabled: true)
var preferences = NotificationService.NotificationPreferences(fromConfig: configManager)
preferences.sessionExit = true
service.updatePreferences(preferences)
@ -205,6 +275,7 @@ struct NotificationServiceTests {
await service.sendSessionExitNotification(sessionName: "", exitCode: 0)
// Should handle gracefully
#expect(configManager.notificationsEnabled == true)
#expect(preferences.sessionExit == true)
}
}