mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-18 13:25:52 +00:00
feat: Add comprehensive notification system with Codable models and modern Swift concurrency (#475)
This commit is contained in:
parent
845d193115
commit
a90f37e4cb
10 changed files with 988 additions and 279 deletions
|
|
@ -31,10 +31,8 @@ let package = Package(
|
|||
"VibeTunnel.entitlements",
|
||||
"Shared.xcconfig",
|
||||
"version.xcconfig",
|
||||
"version.xcconfig.bak",
|
||||
"sparkle-public-ed-key.txt",
|
||||
"Assets.xcassets",
|
||||
"AppIcon.icon",
|
||||
"VibeTunnelApp.swift"
|
||||
]
|
||||
),
|
||||
|
|
|
|||
|
|
@ -58,34 +58,35 @@ final class NotificationControlHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
let type = payload["type"] as? String
|
||||
// Try to parse as ServerEvent-compatible structure
|
||||
let typeString = payload["type"] as? String
|
||||
let sessionId = payload["sessionId"] as? String
|
||||
let sessionName = payload["sessionName"] as? String
|
||||
let exitCode = payload["exitCode"] as? Int
|
||||
let duration = payload["duration"] as? Int
|
||||
let command = payload["command"] as? String
|
||||
|
||||
logger.info("Received notification: \(title) - \(body) (type: \(type ?? "unknown"))")
|
||||
logger.info("Received notification: \(title) - \(body) (type: \(typeString ?? "unknown"))")
|
||||
|
||||
// Check notification type and send appropriate notification
|
||||
switch type {
|
||||
case "session-start":
|
||||
await notificationService.sendSessionStartNotification(
|
||||
sessionName: sessionName ?? "New Session"
|
||||
)
|
||||
case "session-exit":
|
||||
await notificationService.sendSessionExitNotification(
|
||||
sessionName: sessionName ?? "Session",
|
||||
exitCode: 0
|
||||
)
|
||||
case "your-turn":
|
||||
// For "your turn" notifications, use command completion notification
|
||||
await notificationService.sendCommandCompletionNotification(
|
||||
command: sessionName ?? "Command",
|
||||
duration: 0
|
||||
)
|
||||
default:
|
||||
// Fallback to generic notification
|
||||
await notificationService.sendGenericNotification(
|
||||
title: title,
|
||||
body: body
|
||||
// Map type string to ServerEventType and create ServerEvent
|
||||
if let typeString = typeString,
|
||||
let eventType = ServerEventType(rawValue: typeString) {
|
||||
|
||||
let serverEvent = ServerEvent(
|
||||
type: eventType,
|
||||
sessionId: sessionId,
|
||||
sessionName: sessionName ?? title,
|
||||
command: command,
|
||||
exitCode: exitCode,
|
||||
duration: duration,
|
||||
message: body
|
||||
)
|
||||
|
||||
// Use the consolidated notification method
|
||||
await notificationService.sendNotification(for: serverEvent)
|
||||
} else {
|
||||
// Unknown event type - log and ignore
|
||||
logger.warning("Unknown event type '\(typeString ?? "nil")' - ignoring notification request")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -94,10 +95,14 @@ final class NotificationControlHandler {
|
|||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// Notification payload that can be converted to ServerEvent
|
||||
private struct NotificationPayload: Codable {
|
||||
let title: String
|
||||
let body: String
|
||||
let type: String?
|
||||
let sessionId: String?
|
||||
let sessionName: String?
|
||||
let command: String?
|
||||
let exitCode: Int?
|
||||
let duration: Int?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,69 +133,99 @@ final class NotificationService: NSObject {
|
|||
|
||||
// MARK: - Public Notification Methods
|
||||
|
||||
/// Send a session start notification
|
||||
func sendSessionStartNotification(sessionName: String) async {
|
||||
guard 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
|
||||
func sendSessionExitNotification(sessionName: String, exitCode: Int) async {
|
||||
guard 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)"
|
||||
/// Send a notification for a server event
|
||||
/// - Parameter event: The server event to create a notification for
|
||||
func sendNotification(for event: ServerEvent) async {
|
||||
// Check preferences based on event type
|
||||
switch event.type {
|
||||
case .sessionStart:
|
||||
guard preferences.sessionStart else { return }
|
||||
case .sessionExit:
|
||||
guard preferences.sessionExit else { return }
|
||||
case .commandFinished:
|
||||
guard preferences.commandCompletion else { return }
|
||||
case .commandError:
|
||||
guard preferences.commandError else { return }
|
||||
case .bell:
|
||||
guard preferences.bell else { return }
|
||||
case .claudeTurn:
|
||||
guard preferences.claudeTurn else { return }
|
||||
case .connected:
|
||||
// Connected events don't trigger notifications
|
||||
return
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "session-exit-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
/// Send a command completion notification (also used for "Your Turn")
|
||||
func sendCommandCompletionNotification(command: String, duration: Int) async {
|
||||
guard preferences.commandCompletion else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Your Turn"
|
||||
content.body = command
|
||||
content.sound = getNotificationSound()
|
||||
content.categoryIdentifier = "COMMAND"
|
||||
content.interruptionLevel = .active
|
||||
|
||||
if duration > 0 {
|
||||
let seconds = duration / 1_000
|
||||
if seconds > 60 {
|
||||
content.subtitle = "Duration: \(seconds / 60)m \(seconds % 60)s"
|
||||
} else {
|
||||
content.subtitle = "Duration: \(seconds)s"
|
||||
|
||||
// Configure notification based on event type
|
||||
switch event.type {
|
||||
case .sessionStart:
|
||||
content.title = "Session Started"
|
||||
content.body = event.displayName
|
||||
content.categoryIdentifier = "SESSION"
|
||||
content.interruptionLevel = .passive
|
||||
|
||||
case .sessionExit:
|
||||
content.title = "Session Ended"
|
||||
content.body = event.displayName
|
||||
content.categoryIdentifier = "SESSION"
|
||||
if let exitCode = event.exitCode, exitCode != 0 {
|
||||
content.subtitle = "Exit code: \(exitCode)"
|
||||
}
|
||||
|
||||
case .commandFinished:
|
||||
content.title = "Your Turn"
|
||||
content.body = event.command ?? event.displayName
|
||||
content.categoryIdentifier = "COMMAND"
|
||||
content.interruptionLevel = .active
|
||||
if let duration = event.duration, duration > 0, let formattedDuration = event.formattedDuration {
|
||||
content.subtitle = formattedDuration
|
||||
}
|
||||
|
||||
case .commandError:
|
||||
content.title = "Command Failed"
|
||||
content.body = event.command ?? event.displayName
|
||||
content.categoryIdentifier = "COMMAND"
|
||||
if let exitCode = event.exitCode {
|
||||
content.subtitle = "Exit code: \(exitCode)"
|
||||
}
|
||||
|
||||
case .bell:
|
||||
content.title = "Terminal Bell"
|
||||
content.body = event.displayName
|
||||
content.categoryIdentifier = "BELL"
|
||||
if let message = event.message {
|
||||
content.subtitle = message
|
||||
}
|
||||
|
||||
case .claudeTurn:
|
||||
content.title = event.type.description
|
||||
content.body = event.message ?? "Claude has finished responding"
|
||||
content.subtitle = event.displayName
|
||||
content.categoryIdentifier = "CLAUDE_TURN"
|
||||
content.interruptionLevel = .active
|
||||
|
||||
case .connected:
|
||||
return // Already handled above
|
||||
}
|
||||
|
||||
// Set sound based on event type
|
||||
content.sound = event.type == .commandError ? getNotificationSound(critical: true) : getNotificationSound()
|
||||
|
||||
// Add session ID to user info if available
|
||||
if let sessionId = event.sessionId {
|
||||
content.userInfo = ["sessionId": sessionId, "type": event.type.rawValue]
|
||||
}
|
||||
|
||||
// Generate identifier
|
||||
let identifier = "\(event.type.rawValue)-\(event.sessionId ?? UUID().uuidString)"
|
||||
|
||||
// Deliver notification with appropriate method
|
||||
if event.type == .sessionStart {
|
||||
deliverNotificationWithAutoDismiss(content, identifier: identifier, dismissAfter: 5.0)
|
||||
} else {
|
||||
deliverNotification(content, identifier: identifier)
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "command-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
/// Send a generic notification
|
||||
func sendGenericNotification(title: String, body: String) async {
|
||||
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
|
||||
|
|
@ -331,18 +361,18 @@ final class NotificationService: NSObject {
|
|||
let sessions = await SessionMonitor.shared.getSessions()
|
||||
|
||||
for (sessionId, session) in sessions where session.isRunning {
|
||||
let sessionName = session.name ?? session.command.joined(separator: " ")
|
||||
let sessionName = session.name
|
||||
self.logger.info("📨 Sending synthetic session-start event for existing session: \(sessionId)")
|
||||
|
||||
// Create synthetic event data
|
||||
let eventData: [String: Any] = [
|
||||
"type": "session-start",
|
||||
"sessionId": sessionId,
|
||||
"sessionName": sessionName
|
||||
]
|
||||
// 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(eventData)
|
||||
self.handleSessionStart(syntheticEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -382,72 +412,72 @@ final class NotificationService: NSObject {
|
|||
}
|
||||
|
||||
do {
|
||||
guard let jsonData = data.data(using: .utf8),
|
||||
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
||||
let type = json["type"] as? String
|
||||
else {
|
||||
logger.error("🔴 Invalid event data format: \(data)")
|
||||
guard let jsonData = data.data(using: .utf8) else {
|
||||
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)")
|
||||
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: \(type)")
|
||||
logger.info("📨 Received event: \(serverEvent.type.rawValue)")
|
||||
|
||||
switch type {
|
||||
case "session-start":
|
||||
logger.info("🚀 Processing session-start event")
|
||||
if preferences.sessionStart {
|
||||
handleSessionStart(json)
|
||||
} else {
|
||||
logger.debug("Session start notifications disabled")
|
||||
// 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 preferences.sessionExit {
|
||||
handleSessionExit(json)
|
||||
} else {
|
||||
logger.debug("Session exit notifications disabled")
|
||||
}
|
||||
case "command-finished":
|
||||
logger.info("✅ Processing command-finished event")
|
||||
if preferences.commandCompletion {
|
||||
handleCommandFinished(json)
|
||||
} else {
|
||||
logger.debug("Command completion notifications disabled")
|
||||
}
|
||||
case "command-error":
|
||||
logger.info("❌ Processing command-error event")
|
||||
if preferences.commandError {
|
||||
handleCommandError(json)
|
||||
} else {
|
||||
logger.debug("Command error notifications disabled")
|
||||
}
|
||||
case "bell":
|
||||
logger.info("🔔 Processing bell event")
|
||||
if preferences.bell {
|
||||
handleBell(json)
|
||||
} else {
|
||||
logger.debug("Bell notifications disabled")
|
||||
}
|
||||
case "claude-turn":
|
||||
logger.info("💬 Processing claude-turn event")
|
||||
if preferences.claudeTurn {
|
||||
handleClaudeTurn(json)
|
||||
} else {
|
||||
logger.debug("Claude turn notifications disabled")
|
||||
}
|
||||
default:
|
||||
logger.debug("⚠️ Unhandled event type: \(type)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("🔴 Failed to parse event: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSessionStart(_ data: [String: Any]) {
|
||||
guard let sessionName = data["sessionName"] as? String else { return }
|
||||
|
||||
private func handleSessionStart(_ event: ServerEvent) {
|
||||
// Check for duplicate notifications
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
if let sessionId = event.sessionId {
|
||||
if recentlyNotifiedSessions.contains(sessionId) {
|
||||
logger.debug("Skipping duplicate notification for session \(sessionId)")
|
||||
return
|
||||
|
|
@ -461,126 +491,10 @@ final class NotificationService: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Session Started"
|
||||
content.body = sessionName
|
||||
content.sound = getNotificationSound()
|
||||
content.categoryIdentifier = "SESSION"
|
||||
content.interruptionLevel = .passive // Less intrusive for auto-dismiss
|
||||
|
||||
let identifier: String
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
content.userInfo = ["sessionId": sessionId, "type": "session-start"]
|
||||
identifier = "session-start-\(sessionId)"
|
||||
} else {
|
||||
identifier = "session-start-\(UUID().uuidString)"
|
||||
// Use the consolidated notification method
|
||||
Task {
|
||||
await sendNotification(for: event)
|
||||
}
|
||||
|
||||
// Deliver notification with auto-dismiss
|
||||
deliverNotificationWithAutoDismiss(content, identifier: identifier, dismissAfter: 5.0)
|
||||
}
|
||||
|
||||
private func handleSessionExit(_ data: [String: Any]) {
|
||||
guard let sessionName = data["sessionName"] as? String else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Session Ended"
|
||||
content.body = sessionName
|
||||
content.sound = getNotificationSound()
|
||||
content.categoryIdentifier = "SESSION"
|
||||
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
content.userInfo = ["sessionId": sessionId, "type": "session-exit"]
|
||||
}
|
||||
|
||||
if let exitCode = data["exitCode"] as? Int, exitCode != 0 {
|
||||
content.subtitle = "Exit code: \(exitCode)"
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "session-exit-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
private func handleCommandFinished(_ data: [String: Any]) {
|
||||
guard let command = data["command"] as? String else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Command Completed"
|
||||
content.body = command
|
||||
content.sound = getNotificationSound()
|
||||
content.categoryIdentifier = "COMMAND"
|
||||
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
content.userInfo = ["sessionId": sessionId, "type": "command-finished"]
|
||||
}
|
||||
|
||||
if let duration = data["duration"] as? Int {
|
||||
let seconds = duration / 1_000
|
||||
if seconds > 60 {
|
||||
content.subtitle = "Duration: \(seconds / 60)m \(seconds % 60)s"
|
||||
} else {
|
||||
content.subtitle = "Duration: \(seconds)s"
|
||||
}
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "command-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
private func handleCommandError(_ data: [String: Any]) {
|
||||
guard let command = data["command"] as? String else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Command Failed"
|
||||
content.body = command
|
||||
content.sound = getNotificationSound(critical: true)
|
||||
content.categoryIdentifier = "COMMAND"
|
||||
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
content.userInfo = ["sessionId": sessionId, "type": "command-error"]
|
||||
}
|
||||
|
||||
if let exitCode = data["exitCode"] as? Int {
|
||||
content.subtitle = "Exit code: \(exitCode)"
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "command-error-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
private func handleBell(_ data: [String: Any]) {
|
||||
guard let sessionName = data["sessionName"] as? String else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Terminal Bell"
|
||||
content.body = sessionName
|
||||
content.sound = getNotificationSound()
|
||||
content.categoryIdentifier = "BELL"
|
||||
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
content.userInfo = ["sessionId": sessionId, "type": "bell"]
|
||||
}
|
||||
|
||||
if let processInfo = data["processInfo"] as? String {
|
||||
content.subtitle = processInfo
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "bell-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
private func handleClaudeTurn(_ data: [String: Any]) {
|
||||
guard let sessionName = data["sessionName"] as? String else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Your Turn"
|
||||
content.body = "Claude has finished responding"
|
||||
content.subtitle = sessionName
|
||||
content.sound = getNotificationSound()
|
||||
content.categoryIdentifier = "CLAUDE_TURN"
|
||||
content.interruptionLevel = .active
|
||||
|
||||
if let sessionId = data["sessionId"] as? String {
|
||||
content.userInfo = ["sessionId": sessionId, "type": "claude-turn"]
|
||||
}
|
||||
|
||||
deliverNotification(content, identifier: "claude-turn-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
private func deliverNotification(_ content: UNMutableNotificationContent, identifier: String) {
|
||||
|
|
@ -626,7 +540,13 @@ final class NotificationService: NSObject {
|
|||
|
||||
// MARK: - EventSource
|
||||
|
||||
/// Simple Server-Sent Events client
|
||||
/// 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?
|
||||
|
|
@ -637,9 +557,13 @@ private final class EventSource: NSObject, URLSessionDataDelegate, @unchecked Se
|
|||
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?
|
||||
}
|
||||
|
||||
|
|
|
|||
360
mac/VibeTunnel/Core/Services/ServerEvent.swift
Normal file
360
mac/VibeTunnel/Core/Services/ServerEvent.swift
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
// Server event model for notification handling
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Types of server events that can be received from the VibeTunnel server.
|
||||
///
|
||||
/// `ServerEventType` defines all possible event types that flow through the Server-Sent Events (SSE)
|
||||
/// connection between the VibeTunnel server and the macOS app. Each event type corresponds to
|
||||
/// a specific terminal session lifecycle event or user interaction.
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Event Categories
|
||||
///
|
||||
/// - ``sessionStart``: Terminal session creation events
|
||||
/// - ``sessionExit``: Terminal session termination events
|
||||
/// - ``commandFinished``: Command completion events
|
||||
/// - ``commandError``: Command failure events
|
||||
/// - ``bell``: Terminal bell notifications
|
||||
/// - ``claudeTurn``: AI assistant interaction events
|
||||
/// - ``connected``: Connection establishment events
|
||||
///
|
||||
/// ### Event Properties
|
||||
///
|
||||
/// - ``description``: Human-readable event descriptions
|
||||
/// - ``shouldNotify``: Notification eligibility
|
||||
enum ServerEventType: String, Codable, CaseIterable {
|
||||
/// Indicates a new terminal session has been started.
|
||||
case sessionStart = "session-start"
|
||||
|
||||
/// Indicates a terminal session has ended.
|
||||
case sessionExit = "session-exit"
|
||||
|
||||
/// Indicates a command has finished executing successfully.
|
||||
case commandFinished = "command-finished"
|
||||
|
||||
/// Indicates a command has failed with an error.
|
||||
case commandError = "command-error"
|
||||
|
||||
/// Indicates a terminal bell character was received.
|
||||
case bell = "bell"
|
||||
|
||||
/// Indicates Claude (AI assistant) has finished responding and it's the user's turn.
|
||||
case claudeTurn = "claude-turn"
|
||||
|
||||
/// Indicates the SSE connection has been established.
|
||||
case connected = "connected"
|
||||
|
||||
/// Returns a human-readable description of the event type.
|
||||
///
|
||||
/// This property provides user-friendly labels suitable for display in
|
||||
/// notifications and UI elements.
|
||||
var description: String {
|
||||
switch self {
|
||||
case .sessionStart:
|
||||
return "Session Started"
|
||||
case .sessionExit:
|
||||
return "Session Ended"
|
||||
case .commandFinished:
|
||||
return "Command Completed"
|
||||
case .commandError:
|
||||
return "Command Error"
|
||||
case .bell:
|
||||
return "Terminal Bell"
|
||||
case .claudeTurn:
|
||||
return "Your Turn"
|
||||
case .connected:
|
||||
return "Connected"
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines whether this event type should trigger a user notification.
|
||||
///
|
||||
/// This property helps filter which events should result in system notifications.
|
||||
/// Currently, session lifecycle events and Claude turn events are eligible for
|
||||
/// notifications, while command completion and system events are not.
|
||||
///
|
||||
/// - Returns: `true` if the event should trigger a notification, `false` otherwise.
|
||||
var shouldNotify: Bool {
|
||||
switch self {
|
||||
case .sessionStart, .sessionExit, .claudeTurn:
|
||||
return true
|
||||
case .commandFinished, .commandError, .bell, .connected:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a server event received via Server-Sent Events (SSE).
|
||||
///
|
||||
/// `ServerEvent` encapsulates all the information about terminal session events that flow
|
||||
/// from the VibeTunnel server to the macOS app. Each event carries contextual information
|
||||
/// about what happened, when it happened, and which session it relates to.
|
||||
///
|
||||
/// ## Overview
|
||||
///
|
||||
/// Server events are the primary communication mechanism for real-time updates about
|
||||
/// terminal sessions. They enable the macOS app to:
|
||||
/// - Track session lifecycle (creation, termination)
|
||||
/// - Monitor command execution and completion
|
||||
/// - Detect AI assistant interactions
|
||||
/// - Handle system notifications like terminal bells
|
||||
///
|
||||
/// ## Topics
|
||||
///
|
||||
/// ### Creating Events
|
||||
///
|
||||
/// - ``init(type:sessionId:sessionName:command:exitCode:duration:processInfo:message:timestamp:)``
|
||||
/// - ``sessionStart(sessionId:sessionName:command:)``
|
||||
/// - ``sessionExit(sessionId:sessionName:exitCode:)``
|
||||
/// - ``commandFinished(sessionId:command:duration:exitCode:)``
|
||||
/// - ``claudeTurn(sessionId:sessionName:)``
|
||||
/// - ``bell(sessionId:)``
|
||||
///
|
||||
/// ### Event Properties
|
||||
///
|
||||
/// - ``type``: The type of event
|
||||
/// - ``sessionId``: Associated session identifier
|
||||
/// - ``sessionName``: Human-readable session name
|
||||
/// - ``command``: Command that was executed
|
||||
/// - ``exitCode``: Process exit code
|
||||
/// - ``duration``: Execution duration in milliseconds
|
||||
/// - ``processInfo``: Additional process information
|
||||
/// - ``message``: Event message
|
||||
/// - ``timestamp``: When the event occurred
|
||||
///
|
||||
/// ### Computed Properties
|
||||
///
|
||||
/// - ``displayName``: User-friendly name for display
|
||||
/// - ``shouldNotify``: Whether to show a notification
|
||||
/// - ``formattedDuration``: Human-readable duration
|
||||
/// - ``formattedTimestamp``: Formatted timestamp
|
||||
struct ServerEvent: Codable, Identifiable, Equatable {
|
||||
/// Unique identifier for the event instance.
|
||||
let id = UUID()
|
||||
|
||||
/// The type of server event.
|
||||
let type: ServerEventType
|
||||
|
||||
/// The terminal session identifier this event relates to.
|
||||
let sessionId: String?
|
||||
|
||||
/// Human-readable name of the session.
|
||||
let sessionName: String?
|
||||
|
||||
/// The command that was executed (for command-related events).
|
||||
let command: String?
|
||||
|
||||
/// The process exit code (for exit and error events).
|
||||
let exitCode: Int?
|
||||
|
||||
/// Duration in milliseconds (for command completion events).
|
||||
let duration: Int?
|
||||
|
||||
/// Additional process information.
|
||||
let processInfo: String?
|
||||
|
||||
/// Optional message providing additional context.
|
||||
let message: String?
|
||||
|
||||
/// When the event occurred.
|
||||
let timestamp: Date
|
||||
|
||||
/// Creates a new server event with the specified properties.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - type: The type of event.
|
||||
/// - sessionId: Optional session identifier.
|
||||
/// - sessionName: Optional human-readable session name.
|
||||
/// - command: Optional command that was executed.
|
||||
/// - exitCode: Optional process exit code.
|
||||
/// - duration: Optional duration in milliseconds.
|
||||
/// - processInfo: Optional additional process information.
|
||||
/// - message: Optional contextual message.
|
||||
/// - timestamp: When the event occurred (defaults to current time).
|
||||
init(
|
||||
type: ServerEventType,
|
||||
sessionId: String? = nil,
|
||||
sessionName: String? = nil,
|
||||
command: String? = nil,
|
||||
exitCode: Int? = nil,
|
||||
duration: Int? = nil,
|
||||
processInfo: String? = nil,
|
||||
message: String? = nil,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.type = type
|
||||
self.sessionId = sessionId
|
||||
self.sessionName = sessionName
|
||||
self.command = command
|
||||
self.exitCode = exitCode
|
||||
self.duration = duration
|
||||
self.processInfo = processInfo
|
||||
self.message = message
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
/// Creates a session start event.
|
||||
///
|
||||
/// Use this convenience method when a new terminal session is created.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sessionId: The unique identifier for the session.
|
||||
/// - sessionName: Optional human-readable name for the session.
|
||||
/// - command: Optional command that started the session.
|
||||
/// - Returns: A configured `ServerEvent` of type ``ServerEventType/sessionStart``.
|
||||
static func sessionStart(sessionId: String, sessionName: String? = nil, command: String? = nil) -> ServerEvent {
|
||||
ServerEvent(
|
||||
type: .sessionStart,
|
||||
sessionId: sessionId,
|
||||
sessionName: sessionName,
|
||||
command: command
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a session exit event.
|
||||
///
|
||||
/// Use this convenience method when a terminal session ends.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sessionId: The unique identifier for the session.
|
||||
/// - sessionName: Optional human-readable name for the session.
|
||||
/// - exitCode: Optional process exit code.
|
||||
/// - Returns: A configured `ServerEvent` of type ``ServerEventType/sessionExit``.
|
||||
static func sessionExit(sessionId: String, sessionName: String? = nil, exitCode: Int? = nil) -> ServerEvent {
|
||||
ServerEvent(
|
||||
type: .sessionExit,
|
||||
sessionId: sessionId,
|
||||
sessionName: sessionName,
|
||||
exitCode: exitCode
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a command finished event.
|
||||
///
|
||||
/// Use this convenience method when a command completes execution.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sessionId: The unique identifier for the session.
|
||||
/// - command: The command that was executed.
|
||||
/// - duration: Execution time in milliseconds.
|
||||
/// - exitCode: Optional process exit code.
|
||||
/// - Returns: A configured `ServerEvent` of type ``ServerEventType/commandFinished``.
|
||||
static func commandFinished(sessionId: String, command: String, duration: Int, exitCode: Int? = nil) -> ServerEvent {
|
||||
ServerEvent(
|
||||
type: .commandFinished,
|
||||
sessionId: sessionId,
|
||||
command: command,
|
||||
exitCode: exitCode,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a command error event.
|
||||
///
|
||||
/// Use this convenience method when a command fails with a non-zero exit code.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sessionId: The unique identifier for the session.
|
||||
/// - command: The command that failed.
|
||||
/// - exitCode: The process exit code.
|
||||
/// - duration: Optional execution time in milliseconds.
|
||||
/// - Returns: A configured `ServerEvent` of type ``ServerEventType/commandError``.
|
||||
static func commandError(sessionId: String, command: String, exitCode: Int, duration: Int? = nil) -> ServerEvent {
|
||||
ServerEvent(
|
||||
type: .commandError,
|
||||
sessionId: sessionId,
|
||||
command: command,
|
||||
exitCode: exitCode,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a Claude turn event.
|
||||
///
|
||||
/// Use this convenience method when Claude (AI assistant) finishes responding
|
||||
/// and it's the user's turn to interact.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sessionId: The unique identifier for the session.
|
||||
/// - sessionName: Optional human-readable name for the session.
|
||||
/// - Returns: A configured `ServerEvent` of type ``ServerEventType/claudeTurn``.
|
||||
static func claudeTurn(sessionId: String, sessionName: String? = nil) -> ServerEvent {
|
||||
ServerEvent(
|
||||
type: .claudeTurn,
|
||||
sessionId: sessionId,
|
||||
sessionName: sessionName,
|
||||
message: "Claude has finished responding"
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a bell event.
|
||||
///
|
||||
/// Use this convenience method when a terminal bell character is received.
|
||||
///
|
||||
/// - Parameter sessionId: The unique identifier for the session.
|
||||
/// - Returns: A configured `ServerEvent` of type ``ServerEventType/bell``.
|
||||
static func bell(sessionId: String) -> ServerEvent {
|
||||
ServerEvent(
|
||||
type: .bell,
|
||||
sessionId: sessionId,
|
||||
message: "Terminal bell"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Returns a user-friendly display name for the event.
|
||||
///
|
||||
/// The display name is determined by the following priority:
|
||||
/// 1. Session name (if available)
|
||||
/// 2. Command (if available)
|
||||
/// 3. Session ID (if available)
|
||||
/// 4. "Unknown Session" as fallback
|
||||
var displayName: String {
|
||||
sessionName ?? command ?? sessionId ?? "Unknown Session"
|
||||
}
|
||||
|
||||
/// Determines whether this event should trigger a user notification.
|
||||
///
|
||||
/// This delegates to the event type's ``ServerEventType/shouldNotify`` property.
|
||||
var shouldNotify: Bool {
|
||||
type.shouldNotify
|
||||
}
|
||||
|
||||
/// Returns a human-readable formatted duration string.
|
||||
///
|
||||
/// The duration is formatted based on its length:
|
||||
/// - Less than 1 second: Shows milliseconds (e.g., "500ms")
|
||||
/// - Less than 1 minute: Shows seconds with one decimal (e.g., "2.5s")
|
||||
/// - 1 minute or more: Shows minutes and seconds (e.g., "2m 5s")
|
||||
///
|
||||
/// - Returns: A formatted duration string, or `nil` if no duration is set.
|
||||
var formattedDuration: String? {
|
||||
guard let duration = duration else { return nil }
|
||||
|
||||
if duration < 1000 {
|
||||
return "\(duration)ms"
|
||||
} else if duration < 60000 {
|
||||
return String(format: "%.1fs", Double(duration) / 1000.0)
|
||||
} else {
|
||||
let minutes = duration / 60000
|
||||
let seconds = (duration % 60000) / 1000
|
||||
return "\(minutes)m \(seconds)s"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a formatted timestamp string.
|
||||
///
|
||||
/// The timestamp is formatted using medium time style, which typically
|
||||
/// shows hours, minutes, and seconds (e.g., "3:45:32 PM").
|
||||
var formattedTimestamp: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: timestamp)
|
||||
}
|
||||
}
|
||||
|
|
@ -315,10 +315,13 @@ final class SessionMonitor {
|
|||
if previousActive && !currentActive && !alreadyNotified {
|
||||
logger.info("🔔 Detected Claude transition to idle for session: \(id)")
|
||||
let sessionName = newSession.name ?? newSession.command.joined(separator: " ")
|
||||
await NotificationService.shared.sendCommandCompletionNotification(
|
||||
command: sessionName,
|
||||
duration: 0
|
||||
|
||||
// Create a claude-turn event for the notification
|
||||
let claudeTurnEvent = ServerEvent.claudeTurn(
|
||||
sessionId: id,
|
||||
sessionName: sessionName
|
||||
)
|
||||
await NotificationService.shared.sendNotification(for: claudeTurnEvent)
|
||||
claudeIdleNotified.insert(id)
|
||||
}
|
||||
|
||||
|
|
|
|||
272
mac/VibeTunnelTests/ServerEventTests.swift
Normal file
272
mac/VibeTunnelTests/ServerEventTests.swift
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import Testing
|
||||
import Foundation
|
||||
@testable import VibeTunnel
|
||||
|
||||
@Suite("ServerEvent")
|
||||
struct ServerEventTests {
|
||||
|
||||
// MARK: - Codable Tests
|
||||
// These are valuable - testing JSON encoding/decoding with optional fields
|
||||
|
||||
@Test("Codable round-trip with multiple optional fields")
|
||||
func codableRoundTrip() throws {
|
||||
let originalEvent = ServerEvent(
|
||||
type: .sessionStart,
|
||||
sessionId: "test-session-123",
|
||||
sessionName: "Test Session",
|
||||
command: "ls -la",
|
||||
exitCode: nil,
|
||||
duration: nil,
|
||||
processInfo: nil,
|
||||
message: "Session started successfully"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(originalEvent)
|
||||
let decodedEvent = try JSONDecoder().decode(ServerEvent.self, from: data)
|
||||
|
||||
#expect(originalEvent.type == decodedEvent.type)
|
||||
#expect(originalEvent.sessionId == decodedEvent.sessionId)
|
||||
#expect(originalEvent.sessionName == decodedEvent.sessionName)
|
||||
#expect(originalEvent.command == decodedEvent.command)
|
||||
#expect(originalEvent.message == decodedEvent.message)
|
||||
}
|
||||
|
||||
@Test("Codable with all fields populated")
|
||||
func codableWithAllFields() throws {
|
||||
let event = ServerEvent(
|
||||
type: .commandFinished,
|
||||
sessionId: "session-456",
|
||||
sessionName: "Long Running Command",
|
||||
command: "npm install",
|
||||
exitCode: 0,
|
||||
duration: 15000,
|
||||
processInfo: "Node.js process",
|
||||
message: "Command completed successfully"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(event)
|
||||
let decoded = try JSONDecoder().decode(ServerEvent.self, from: data)
|
||||
|
||||
#expect(decoded.type == .commandFinished)
|
||||
#expect(decoded.sessionId == "session-456")
|
||||
#expect(decoded.sessionName == "Long Running Command")
|
||||
#expect(decoded.command == "npm install")
|
||||
#expect(decoded.exitCode == 0)
|
||||
#expect(decoded.duration == 15000)
|
||||
#expect(decoded.processInfo == "Node.js process")
|
||||
#expect(decoded.message == "Command completed successfully")
|
||||
}
|
||||
|
||||
@Test("Codable with minimal fields preserves nils")
|
||||
func codableWithMinimalFields() throws {
|
||||
let event = ServerEvent(type: .bell)
|
||||
|
||||
let data = try JSONEncoder().encode(event)
|
||||
let decoded = try JSONDecoder().decode(ServerEvent.self, from: data)
|
||||
|
||||
#expect(decoded.type == .bell)
|
||||
#expect(decoded.sessionId == nil)
|
||||
#expect(decoded.sessionName == nil)
|
||||
#expect(decoded.command == nil)
|
||||
#expect(decoded.exitCode == nil)
|
||||
#expect(decoded.duration == nil)
|
||||
#expect(decoded.processInfo == nil)
|
||||
#expect(decoded.message == nil)
|
||||
#expect(decoded.timestamp != nil)
|
||||
}
|
||||
|
||||
// MARK: - Event Type Logic Tests
|
||||
// Testing actual business logic, not Swift's enum implementation
|
||||
|
||||
@Test("Event type descriptions are user-friendly")
|
||||
func eventTypeDescriptions() {
|
||||
#expect(ServerEventType.sessionStart.description == "Session Started")
|
||||
#expect(ServerEventType.sessionExit.description == "Session Ended")
|
||||
#expect(ServerEventType.commandFinished.description == "Command Completed")
|
||||
#expect(ServerEventType.commandError.description == "Command Error")
|
||||
#expect(ServerEventType.bell.description == "Terminal Bell")
|
||||
#expect(ServerEventType.claudeTurn.description == "Your Turn")
|
||||
#expect(ServerEventType.connected.description == "Connected")
|
||||
}
|
||||
|
||||
@Test("shouldNotify returns correct values for notification logic")
|
||||
func eventTypeShouldNotify() {
|
||||
// These events should trigger notifications
|
||||
#expect(ServerEventType.sessionStart.shouldNotify)
|
||||
#expect(ServerEventType.sessionExit.shouldNotify)
|
||||
#expect(ServerEventType.claudeTurn.shouldNotify)
|
||||
|
||||
// These events should not trigger notifications
|
||||
#expect(!ServerEventType.commandFinished.shouldNotify)
|
||||
#expect(!ServerEventType.commandError.shouldNotify)
|
||||
#expect(!ServerEventType.bell.shouldNotify)
|
||||
#expect(!ServerEventType.connected.shouldNotify)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
// These test important edge cases for data integrity
|
||||
|
||||
@Test("Handles empty strings correctly")
|
||||
func handlesEmptyStrings() throws {
|
||||
let event = ServerEvent(
|
||||
type: .sessionStart,
|
||||
sessionId: "",
|
||||
sessionName: "",
|
||||
command: "",
|
||||
message: ""
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(event)
|
||||
let decoded = try JSONDecoder().decode(ServerEvent.self, from: data)
|
||||
|
||||
// Empty strings should be preserved, not converted to nil
|
||||
#expect(decoded.sessionId == "")
|
||||
#expect(decoded.sessionName == "")
|
||||
#expect(decoded.command == "")
|
||||
#expect(decoded.message == "")
|
||||
}
|
||||
|
||||
@Test("Handles special characters in JSON encoding")
|
||||
func handlesSpecialCharacters() throws {
|
||||
let event = ServerEvent(
|
||||
type: .commandError,
|
||||
sessionId: "session-123",
|
||||
sessionName: "Test Session with \"quotes\" and 'apostrophes'",
|
||||
command: "echo 'Hello, World!' && echo \"Test\"",
|
||||
exitCode: -1,
|
||||
message: "Error: Command failed with special chars: <>&\"'"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(event)
|
||||
let decoded = try JSONDecoder().decode(ServerEvent.self, from: data)
|
||||
|
||||
#expect(decoded.sessionName == "Test Session with \"quotes\" and 'apostrophes'")
|
||||
#expect(decoded.command == "echo 'Hello, World!' && echo \"Test\"")
|
||||
#expect(decoded.message == "Error: Command failed with special chars: <>&\"'")
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
// These test that convenience initializers create properly configured events
|
||||
|
||||
@Test("sessionStart convenience initializer sets correct fields")
|
||||
func sessionStartInitializer() {
|
||||
let event = ServerEvent.sessionStart(
|
||||
sessionId: "test-123",
|
||||
sessionName: "Test Session",
|
||||
command: "ls -la"
|
||||
)
|
||||
|
||||
#expect(event.type == .sessionStart)
|
||||
#expect(event.sessionId == "test-123")
|
||||
#expect(event.sessionName == "Test Session")
|
||||
#expect(event.command == "ls -la")
|
||||
#expect(event.shouldNotify)
|
||||
}
|
||||
|
||||
@Test("sessionExit convenience initializer sets correct fields")
|
||||
func sessionExitInitializer() {
|
||||
let event = ServerEvent.sessionExit(
|
||||
sessionId: "test-456",
|
||||
sessionName: "Test Session",
|
||||
exitCode: 0
|
||||
)
|
||||
|
||||
#expect(event.type == .sessionExit)
|
||||
#expect(event.sessionId == "test-456")
|
||||
#expect(event.sessionName == "Test Session")
|
||||
#expect(event.exitCode == 0)
|
||||
#expect(event.shouldNotify)
|
||||
}
|
||||
|
||||
@Test("commandFinished convenience initializer sets correct fields")
|
||||
func commandFinishedInitializer() {
|
||||
let event = ServerEvent.commandFinished(
|
||||
sessionId: "test-789",
|
||||
command: "npm install",
|
||||
duration: 15000,
|
||||
exitCode: 0
|
||||
)
|
||||
|
||||
#expect(event.type == .commandFinished)
|
||||
#expect(event.sessionId == "test-789")
|
||||
#expect(event.command == "npm install")
|
||||
#expect(event.duration == 15000)
|
||||
#expect(event.exitCode == 0)
|
||||
#expect(!event.shouldNotify)
|
||||
}
|
||||
|
||||
@Test("claudeTurn convenience initializer includes default message")
|
||||
func claudeTurnInitializer() {
|
||||
let event = ServerEvent.claudeTurn(
|
||||
sessionId: "claude-session",
|
||||
sessionName: "Claude Chat"
|
||||
)
|
||||
|
||||
#expect(event.type == .claudeTurn)
|
||||
#expect(event.sessionId == "claude-session")
|
||||
#expect(event.sessionName == "Claude Chat")
|
||||
#expect(event.message == "Claude has finished responding")
|
||||
#expect(event.shouldNotify)
|
||||
}
|
||||
|
||||
@Test("bell convenience initializer includes default message")
|
||||
func bellInitializer() {
|
||||
let event = ServerEvent.bell(sessionId: "bell-session")
|
||||
|
||||
#expect(event.type == .bell)
|
||||
#expect(event.sessionId == "bell-session")
|
||||
#expect(event.message == "Terminal bell")
|
||||
#expect(!event.shouldNotify)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties with Logic
|
||||
// These test actual business logic in computed properties
|
||||
|
||||
@Test("displayName fallback logic works correctly")
|
||||
func displayNameLogic() {
|
||||
// Priority 1: Session name
|
||||
let event1 = ServerEvent(type: .sessionStart, sessionName: "My Session")
|
||||
#expect(event1.displayName == "My Session")
|
||||
|
||||
// Priority 2: Command (when no session name)
|
||||
let event2 = ServerEvent(type: .sessionStart, command: "ls -la")
|
||||
#expect(event2.displayName == "ls -la")
|
||||
|
||||
// Priority 3: Session ID (when no name or command)
|
||||
let event3 = ServerEvent(type: .sessionStart, sessionId: "session-123")
|
||||
#expect(event3.displayName == "session-123")
|
||||
|
||||
// Fallback: Unknown Session
|
||||
let event4 = ServerEvent(type: .sessionStart)
|
||||
#expect(event4.displayName == "Unknown Session")
|
||||
}
|
||||
|
||||
@Test("formattedDuration handles different time ranges", arguments: [
|
||||
(500, "500ms"),
|
||||
(2500, "2.5s"),
|
||||
(125000, "2m 5s"),
|
||||
(3661000, "1h 1m 1s")
|
||||
])
|
||||
func formattedDurationLogic(duration: Int, expected: String) {
|
||||
let event = ServerEvent(type: .commandFinished, duration: duration)
|
||||
#expect(event.formattedDuration == expected)
|
||||
}
|
||||
|
||||
@Test("formattedDuration returns nil when duration is nil")
|
||||
func formattedDurationNil() {
|
||||
let event = ServerEvent(type: .sessionStart)
|
||||
#expect(event.formattedDuration == nil)
|
||||
}
|
||||
|
||||
@Test("formattedTimestamp uses correct format")
|
||||
func formattedTimestampFormat() {
|
||||
let timestamp = Date()
|
||||
let event = ServerEvent(type: .sessionStart, timestamp: timestamp)
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .medium
|
||||
let expected = formatter.string(from: timestamp)
|
||||
|
||||
#expect(event.formattedTimestamp == expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import Testing
|
|||
@Suite("System Control Handler Tests", .serialized)
|
||||
struct SystemControlHandlerTests {
|
||||
@MainActor
|
||||
@Test("Handles system ready event")
|
||||
func systemReady() async throws {
|
||||
// Given
|
||||
var systemReadyCalled = false
|
||||
let handler = SystemControlHandler(onSystemReady: {
|
||||
|
|
|
|||
110
mac/VibeTunnelTests/TunnelSessionTests.swift
Normal file
110
mac/VibeTunnelTests/TunnelSessionTests.swift
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import Testing
|
||||
import Foundation
|
||||
@testable import VibeTunnel
|
||||
|
||||
@Suite("TunnelSession & Related Types")
|
||||
struct TunnelSessionTests {
|
||||
|
||||
// MARK: - TunnelSession Logic Tests
|
||||
// Only testing actual logic, not property synthesis
|
||||
|
||||
@Test("updateActivity updates lastActivity timestamp")
|
||||
func updateActivityLogic() async throws {
|
||||
var session = TunnelSession()
|
||||
let originalActivity = session.lastActivity
|
||||
|
||||
// Use async sleep instead of Thread.sleep
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
session.updateActivity()
|
||||
|
||||
#expect(session.lastActivity > originalActivity)
|
||||
}
|
||||
|
||||
@Test("TunnelSession is Codable with all fields")
|
||||
func tunnelSessionCodable() throws {
|
||||
var originalSession = TunnelSession(processID: 67890)
|
||||
originalSession.updateActivity()
|
||||
|
||||
let data = try JSONEncoder().encode(originalSession)
|
||||
let decodedSession = try JSONDecoder().decode(TunnelSession.self, from: data)
|
||||
|
||||
#expect(originalSession.id == decodedSession.id)
|
||||
#expect(originalSession.processID == decodedSession.processID)
|
||||
#expect(originalSession.isActive == decodedSession.isActive)
|
||||
// Using approximate comparison for dates due to encoding precision
|
||||
#expect(abs(originalSession.createdAt.timeIntervalSince(decodedSession.createdAt)) < 0.001)
|
||||
#expect(abs(originalSession.lastActivity.timeIntervalSince(decodedSession.lastActivity)) < 0.001)
|
||||
}
|
||||
|
||||
// MARK: - CreateSessionRequest Tests
|
||||
// Testing optional field handling in Codable
|
||||
|
||||
@Test("CreateSessionRequest encodes/decodes with all optional fields")
|
||||
func createSessionRequestFullCodable() throws {
|
||||
let originalRequest = CreateSessionRequest(
|
||||
workingDirectory: "/test/dir",
|
||||
environment: ["TEST": "value", "PATH": "/usr/bin"],
|
||||
shell: "/bin/bash"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(originalRequest)
|
||||
let decodedRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: data)
|
||||
|
||||
#expect(originalRequest.workingDirectory == decodedRequest.workingDirectory)
|
||||
#expect(originalRequest.environment == decodedRequest.environment)
|
||||
#expect(originalRequest.shell == decodedRequest.shell)
|
||||
}
|
||||
|
||||
@Test("CreateSessionRequest handles empty and nil values correctly")
|
||||
func createSessionRequestEdgeCases() throws {
|
||||
// Test with empty environment (not nil)
|
||||
let requestWithEmpty = CreateSessionRequest(environment: [:])
|
||||
let data1 = try JSONEncoder().encode(requestWithEmpty)
|
||||
let decoded1 = try JSONDecoder().decode(CreateSessionRequest.self, from: data1)
|
||||
#expect(decoded1.environment == [:])
|
||||
|
||||
// Test with all nils
|
||||
let requestWithNils = CreateSessionRequest()
|
||||
let data2 = try JSONEncoder().encode(requestWithNils)
|
||||
let decoded2 = try JSONDecoder().decode(CreateSessionRequest.self, from: data2)
|
||||
#expect(decoded2.workingDirectory == nil)
|
||||
#expect(decoded2.environment == nil)
|
||||
#expect(decoded2.shell == nil)
|
||||
}
|
||||
|
||||
@Test("CreateSessionRequest handles special characters in paths and environment")
|
||||
func createSessionRequestSpecialCharacters() throws {
|
||||
let request = CreateSessionRequest(
|
||||
workingDirectory: "/path/with spaces/and\"quotes\"",
|
||||
environment: ["PATH": "/usr/bin:/usr/local/bin", "HOME": "/home/user with spaces"],
|
||||
shell: "/bin/bash -l"
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(request)
|
||||
let decoded = try JSONDecoder().decode(CreateSessionRequest.self, from: data)
|
||||
|
||||
#expect(decoded.workingDirectory == "/path/with spaces/and\"quotes\"")
|
||||
#expect(decoded.environment?["PATH"] == "/usr/bin:/usr/local/bin")
|
||||
#expect(decoded.environment?["HOME"] == "/home/user with spaces")
|
||||
#expect(decoded.shell == "/bin/bash -l")
|
||||
}
|
||||
|
||||
// MARK: - CreateSessionResponse Tests
|
||||
// Simple type but worth testing Codable with Date precision
|
||||
|
||||
@Test("CreateSessionResponse handles date encoding correctly")
|
||||
func createSessionResponseDateHandling() throws {
|
||||
let originalResponse = CreateSessionResponse(
|
||||
sessionId: "response-test-456",
|
||||
createdAt: Date()
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(originalResponse)
|
||||
let decodedResponse = try JSONDecoder().decode(CreateSessionResponse.self, from: data)
|
||||
|
||||
#expect(originalResponse.sessionId == decodedResponse.sessionId)
|
||||
// Date encoding/decoding can lose some precision
|
||||
#expect(abs(originalResponse.createdAt.timeIntervalSince(decodedResponse.createdAt)) < 0.001)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { type Request, type Response, Router } from 'express';
|
||||
import { type ServerEvent, ServerEventType } from '../../shared/types.js';
|
||||
import type { PtyManager } from '../pty/pty-manager.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
|
|
@ -77,15 +78,19 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
}, 30000);
|
||||
|
||||
// Event handlers
|
||||
const sendEvent = (type: string, data: Record<string, unknown>) => {
|
||||
const event = {
|
||||
const sendEvent = (type: ServerEventType, data: Omit<ServerEvent, 'type' | 'timestamp'>) => {
|
||||
const event: ServerEvent = {
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
...data,
|
||||
};
|
||||
|
||||
// Enhanced logging for all notification events
|
||||
if (type === 'command-finished' || type === 'command-error' || type === 'claude-turn') {
|
||||
if (
|
||||
type === ServerEventType.CommandFinished ||
|
||||
type === ServerEventType.CommandError ||
|
||||
type === ServerEventType.ClaudeTurn
|
||||
) {
|
||||
logger.info(
|
||||
`🔔 NOTIFICATION DEBUG: Actually sending SSE event - type: ${type}, sessionId: ${data.sessionId}`
|
||||
);
|
||||
|
|
@ -93,9 +98,9 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
|
||||
// Enhanced logging for Claude-related events
|
||||
if (
|
||||
(type === 'command-finished' || type === 'command-error') &&
|
||||
(type === ServerEventType.CommandFinished || type === ServerEventType.CommandError) &&
|
||||
data.command &&
|
||||
(data.command as string).toLowerCase().includes('claude')
|
||||
data.command.toLowerCase().includes('claude')
|
||||
) {
|
||||
logger.log(`🚀 SSE: Sending Claude ${type} event for session ${data.sessionId}`);
|
||||
}
|
||||
|
|
@ -114,11 +119,11 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
|
||||
// Listen for session events
|
||||
onSessionStarted = (sessionId: string, sessionName: string) => {
|
||||
sendEvent('session-start', { sessionId, sessionName });
|
||||
sendEvent(ServerEventType.SessionStart, { sessionId, sessionName });
|
||||
};
|
||||
|
||||
onSessionExited = (sessionId: string, sessionName: string, exitCode?: number) => {
|
||||
sendEvent('session-exit', { sessionId, sessionName, exitCode });
|
||||
sendEvent(ServerEventType.SessionExit, { sessionId, sessionName, exitCode });
|
||||
};
|
||||
|
||||
onCommandFinished = (data: CommandFinishedEvent) => {
|
||||
|
|
@ -134,14 +139,14 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
);
|
||||
|
||||
if (data.exitCode === 0) {
|
||||
sendEvent('command-finished', {
|
||||
sendEvent(ServerEventType.CommandFinished, {
|
||||
sessionId: data.sessionId,
|
||||
command: data.command,
|
||||
duration: data.duration,
|
||||
exitCode: data.exitCode,
|
||||
});
|
||||
} else {
|
||||
sendEvent('command-error', {
|
||||
sendEvent(ServerEventType.CommandError, {
|
||||
sessionId: data.sessionId,
|
||||
command: data.command,
|
||||
duration: data.duration,
|
||||
|
|
@ -154,7 +159,7 @@ export function createEventsRouter(ptyManager: PtyManager): Router {
|
|||
logger.info(
|
||||
`🔔 NOTIFICATION DEBUG: SSE forwarding claude-turn event - sessionId: ${sessionId}, sessionName: "${sessionName}"`
|
||||
);
|
||||
sendEvent('claude-turn', {
|
||||
sendEvent(ServerEventType.ClaudeTurn, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
message: 'Claude has finished responding',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,36 @@ export enum HttpMethod {
|
|||
OPTIONS = 'OPTIONS',
|
||||
}
|
||||
|
||||
/**
|
||||
* Types of server events that can be received via Server-Sent Events (SSE).
|
||||
* Matches the Swift ServerEventType enum for type safety across platforms.
|
||||
*/
|
||||
export enum ServerEventType {
|
||||
SessionStart = 'session-start',
|
||||
SessionExit = 'session-exit',
|
||||
CommandFinished = 'command-finished',
|
||||
CommandError = 'command-error',
|
||||
Bell = 'bell',
|
||||
ClaudeTurn = 'claude-turn',
|
||||
Connected = 'connected',
|
||||
}
|
||||
|
||||
/**
|
||||
* Server event received via Server-Sent Events (SSE).
|
||||
* Matches the Swift ServerEvent struct for cross-platform compatibility.
|
||||
*/
|
||||
export interface ServerEvent {
|
||||
type: ServerEventType;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
command?: string;
|
||||
exitCode?: number;
|
||||
duration?: number;
|
||||
processInfo?: string;
|
||||
message?: string;
|
||||
timestamp: string; // ISO 8601 format
|
||||
}
|
||||
|
||||
/**
|
||||
* Session status enum
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue