mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
- Extended control protocol to support session-monitor events - Modified SessionMonitor to emit events via Unix socket to Mac app - Removed duplicate notification logic from Mac SessionMonitor - All notifications now flow: Server → Unix Socket → Mac NotificationControlHandler - Respects user notification preferences on Mac side - Single source of truth for all notification events (server-side) This eliminates the need for SSE connection from Mac app and removes polling-based duplicate detection, simplifying the architecture.
236 lines
No EOL
7.9 KiB
Swift
236 lines
No EOL
7.9 KiB
Swift
import Foundation
|
|
import Observation
|
|
import os.log
|
|
|
|
/// Server session information returned by the API.
|
|
///
|
|
/// Represents the current state of a terminal session running on the VibeTunnel server,
|
|
/// including its command, directory, process status, and activity information.
|
|
struct ServerSessionInfo: Codable {
|
|
let id: String
|
|
let name: String
|
|
let command: [String]
|
|
let workingDir: String
|
|
let status: String
|
|
let exitCode: Int?
|
|
let startedAt: String
|
|
let pid: Int?
|
|
let initialCols: Int?
|
|
let initialRows: Int?
|
|
let lastClearOffset: Int?
|
|
let version: String?
|
|
let gitRepoPath: String?
|
|
let gitBranch: String?
|
|
let gitAheadCount: Int?
|
|
let gitBehindCount: Int?
|
|
let gitHasChanges: Bool?
|
|
let gitIsWorktree: Bool?
|
|
let gitMainRepoPath: String?
|
|
|
|
// Additional fields from Session (not SessionInfo)
|
|
let lastModified: String
|
|
let active: Bool?
|
|
let activityStatus: ActivityStatus?
|
|
let source: String?
|
|
let remoteId: String?
|
|
let remoteName: String?
|
|
let remoteUrl: String?
|
|
let attachedViaVT: Bool?
|
|
|
|
var isRunning: Bool {
|
|
status == "running"
|
|
}
|
|
}
|
|
|
|
/// Activity status for a session.
|
|
///
|
|
/// Tracks whether a session is actively being used and provides
|
|
/// application-specific status information when available.
|
|
struct ActivityStatus: Codable {
|
|
let isActive: Bool
|
|
let specificStatus: SpecificStatus?
|
|
}
|
|
|
|
/// App-specific status information.
|
|
///
|
|
/// Provides detailed status information for specific applications running
|
|
/// within a terminal session, such as Claude's current working state.
|
|
struct SpecificStatus: Codable {
|
|
let app: String
|
|
let status: String
|
|
}
|
|
|
|
/// Lightweight session monitor that fetches terminal sessions on-demand.
|
|
///
|
|
/// Manages the collection of active terminal sessions by periodically polling
|
|
/// the server API and caching results for efficient access. Provides real-time
|
|
/// session information to the UI with minimal network overhead.
|
|
@MainActor
|
|
@Observable
|
|
final class SessionMonitor {
|
|
static let shared = SessionMonitor()
|
|
|
|
/// Previous session states for exit detection
|
|
private var previousSessions: [String: ServerSessionInfo] = [:]
|
|
private var firstFetchDone = false
|
|
|
|
/// Detect sessions that transitioned from running to not running
|
|
static func detectEndedSessions(
|
|
from old: [String: ServerSessionInfo],
|
|
to new: [String: ServerSessionInfo]
|
|
)
|
|
-> [ServerSessionInfo]
|
|
{
|
|
old.compactMap { id, oldSession in
|
|
if oldSession.isRunning,
|
|
let updated = new[id], !updated.isRunning
|
|
{
|
|
return oldSession
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private(set) var sessions: [String: ServerSessionInfo] = [:]
|
|
private(set) var lastError: Error?
|
|
|
|
private var lastFetch: Date?
|
|
private let cacheInterval: TimeInterval = 2.0
|
|
private let serverManager = ServerManager.shared
|
|
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SessionMonitor")
|
|
|
|
/// Reference to GitRepositoryMonitor for pre-caching
|
|
weak var gitRepositoryMonitor: GitRepositoryMonitor?
|
|
|
|
private init() {}
|
|
|
|
/// Set the local auth token for server requests
|
|
func setLocalAuthToken(_ token: String?) {}
|
|
|
|
/// Number of running sessions
|
|
var sessionCount: Int {
|
|
sessions.values.count { $0.isRunning }
|
|
}
|
|
|
|
/// Get all sessions, using cache if available
|
|
func getSessions() async -> [String: ServerSessionInfo] {
|
|
// Use cache if available and fresh
|
|
if let lastFetch, Date().timeIntervalSince(lastFetch) < cacheInterval {
|
|
return sessions
|
|
}
|
|
|
|
await fetchSessions()
|
|
return sessions
|
|
}
|
|
|
|
/// Force refresh session data
|
|
func refresh() async {
|
|
lastFetch = nil
|
|
await fetchSessions()
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func fetchSessions() async {
|
|
do {
|
|
// Snapshot previous sessions for exit notifications
|
|
let _ = sessions
|
|
|
|
let sessionsArray = try await serverManager.performRequest(
|
|
endpoint: APIEndpoints.sessions,
|
|
method: "GET",
|
|
responseType: [ServerSessionInfo].self
|
|
)
|
|
|
|
// Convert to dictionary
|
|
var sessionsDict: [String: ServerSessionInfo] = [:]
|
|
for session in sessionsArray {
|
|
sessionsDict[session.id] = session
|
|
}
|
|
|
|
self.sessions = sessionsDict
|
|
self.lastError = nil
|
|
|
|
// Sessions have been updated
|
|
|
|
// Set firstFetchDone AFTER detecting ended sessions
|
|
firstFetchDone = true
|
|
self.lastFetch = Date()
|
|
|
|
// Update WindowTracker
|
|
WindowTracker.shared.updateFromSessions(sessionsArray)
|
|
|
|
// Pre-cache Git data for all sessions (deduplicated by repository)
|
|
if let gitMonitor = gitRepositoryMonitor {
|
|
await preCacheGitRepositories(for: sessionsArray, using: gitMonitor)
|
|
}
|
|
} catch {
|
|
// Only update error if it's not a simple connection error
|
|
if !(error is URLError) {
|
|
self.lastError = error
|
|
}
|
|
logger.error("Failed to fetch sessions: \(error, privacy: .public)")
|
|
self.sessions = [:]
|
|
self.lastFetch = Date() // Still update timestamp to avoid hammering
|
|
}
|
|
}
|
|
|
|
/// Pre-cache Git repositories for sessions, deduplicating by repository root
|
|
private func preCacheGitRepositories(
|
|
for sessions: [ServerSessionInfo],
|
|
using gitMonitor: GitRepositoryMonitor
|
|
)
|
|
async
|
|
{
|
|
// Track unique directories we need to check
|
|
var uniqueDirectoriesToCheck = Set<String>()
|
|
|
|
// First, collect all unique directories that don't have cached data
|
|
for session in sessions {
|
|
// Skip if we already have cached data for this exact path
|
|
if gitMonitor.getCachedRepository(for: session.workingDir) != nil {
|
|
continue
|
|
}
|
|
|
|
// Add this directory to check
|
|
uniqueDirectoriesToCheck.insert(session.workingDir)
|
|
|
|
// Smart detection: Also check common parent directories
|
|
// This helps when multiple sessions are in subdirectories of the same project
|
|
let pathComponents = session.workingDir.split(separator: "/").map(String.init)
|
|
|
|
// Check if this looks like a project directory pattern
|
|
// Common patterns: /Users/*/Projects/*, /Users/*/Development/*, etc.
|
|
if pathComponents.count >= 4 {
|
|
// Check if we're in a common development directory
|
|
let commonDevPaths = ["Projects", "Development", "Developer", "Code", "Work", "Source"]
|
|
|
|
for (index, component) in pathComponents.enumerated() {
|
|
if commonDevPaths.contains(component) && index < pathComponents.count - 1 {
|
|
// This might be a parent project directory
|
|
// Add the immediate child of the development directory
|
|
let potentialProjectPath = "/" + pathComponents[0...index + 1].joined(separator: "/")
|
|
|
|
// Only add if we don't have cached data for it
|
|
if gitMonitor.getCachedRepository(for: potentialProjectPath) == nil {
|
|
uniqueDirectoriesToCheck.insert(potentialProjectPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now check each unique directory only once
|
|
for directory in uniqueDirectoriesToCheck {
|
|
Task {
|
|
// This will cache the data for immediate access later
|
|
_ = await gitMonitor.findRepository(for: directory)
|
|
}
|
|
}
|
|
|
|
logger
|
|
.debug(
|
|
"Pre-caching Git data for \(uniqueDirectoriesToCheck.count) unique directories (from \(sessions.count) sessions)"
|
|
)
|
|
}
|
|
} |