mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
483 lines
18 KiB
Swift
483 lines
18 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OSLog
|
|
|
|
/// Tracks terminal windows and their associated sessions.
|
|
///
|
|
/// This class provides functionality to:
|
|
/// - Enumerate terminal windows using Core Graphics APIs
|
|
/// - Map VibeTunnel sessions to their terminal windows
|
|
/// - Focus specific terminal windows when requested
|
|
/// - Handle both windows and tabs for different terminal applications
|
|
/// - **Close terminal windows when sessions are terminated (NEW)**
|
|
///
|
|
/// ## Window Closing Feature
|
|
///
|
|
/// A key enhancement is the ability to automatically close terminal windows when
|
|
/// their associated sessions are terminated. This solves the common problem where
|
|
/// killing a long-running process (like `claude`) leaves an empty terminal window.
|
|
///
|
|
/// ### Design Principles:
|
|
/// 1. **Only close what we open**: Windows are only closed if VibeTunnel opened them
|
|
/// 2. **Track ownership at creation**: Sessions opened via AppleScript are marked at launch time
|
|
/// 3. **Respect external sessions**: Sessions attached via `vt` are never closed
|
|
///
|
|
/// ### Implementation:
|
|
/// - When spawning terminals via AppleScript, sessions are marked in `sessionsOpenedByUs` set
|
|
/// - On termination, we dynamically find windows using process tree traversal
|
|
/// - Only windows for sessions in the set are closed
|
|
/// - Currently supports Terminal.app and iTerm2
|
|
///
|
|
/// ### User Experience:
|
|
/// - Consistent behavior: All VibeTunnel-spawned windows close on termination
|
|
/// - No orphaned windows: Prevents accumulation of empty terminals
|
|
/// - External sessions preserved: `vt`-attached terminals remain open
|
|
@MainActor
|
|
final class WindowTracker {
|
|
static let shared = WindowTracker()
|
|
|
|
private let logger = Logger(
|
|
subsystem: BundleIdentifiers.loggerSubsystem,
|
|
category: "WindowTracker"
|
|
)
|
|
|
|
/// Maps session IDs to their terminal window information
|
|
private var sessionWindowMap: [String: WindowInfo] = [:]
|
|
|
|
/// Tracks which sessions we opened via AppleScript (and can close).
|
|
///
|
|
/// When VibeTunnel spawns a terminal session through AppleScript, we mark
|
|
/// it in this set. This allows us to distinguish between:
|
|
/// - Sessions we created: Can and should close their windows
|
|
/// - Sessions attached via `vt`: Should never close their windows
|
|
///
|
|
/// The actual window finding happens dynamically using process tree traversal,
|
|
/// making the system robust against tab reordering and window manipulation.
|
|
///
|
|
/// Example flow:
|
|
/// 1. User creates session via UI → TerminalLauncher uses AppleScript
|
|
/// 2. Session ID is added to this set
|
|
/// 3. User kills session → We find and close the window dynamically
|
|
///
|
|
/// Sessions attached via `vt` command are NOT added to this set.
|
|
private var sessionsOpenedByUs: Set<String> = []
|
|
|
|
/// Lock for thread-safe access to the session map
|
|
private let mapLock = NSLock()
|
|
|
|
// Component instances
|
|
private let windowEnumerator = WindowEnumerator()
|
|
private let windowMatcher = WindowMatcher()
|
|
private let windowFocuser = WindowFocuser()
|
|
private let permissionChecker = PermissionChecker()
|
|
private let processTracker = ProcessTracker()
|
|
|
|
private init() {
|
|
logger.info("WindowTracker initialized")
|
|
}
|
|
|
|
// MARK: - Window Registration
|
|
|
|
/// Registers a session that was opened by VibeTunnel.
|
|
/// This should be called after launching a terminal with a session ID.
|
|
/// Only sessions registered here will have their windows closed on termination.
|
|
func registerSessionOpenedByUs(
|
|
for sessionID: String,
|
|
terminalApp: Terminal
|
|
) {
|
|
logger.info("Registering session opened by us: \(sessionID), terminal: \(terminalApp.rawValue)")
|
|
|
|
// Mark this session as opened by us, so we can close its window later
|
|
// This is the critical point where we distinguish between:
|
|
// - Sessions we created via AppleScript (can close)
|
|
// - Sessions attached via `vt` command (cannot close)
|
|
_ = mapLock.withLock {
|
|
sessionsOpenedByUs.insert(sessionID)
|
|
}
|
|
|
|
// Window finding is now handled dynamically when needed (focus/close)
|
|
// This avoids storing stale tab references
|
|
}
|
|
|
|
/// Legacy method for compatibility - redirects to simplified registration
|
|
func registerWindow(
|
|
for sessionID: String,
|
|
terminalApp: Terminal,
|
|
tabReference: String? = nil,
|
|
tabID: String? = nil
|
|
) {
|
|
// Simply mark the session as opened by us
|
|
// We no longer store tab references as they become stale
|
|
registerSessionOpenedByUs(for: sessionID, terminalApp: terminalApp)
|
|
}
|
|
|
|
/// Unregisters a window for a session.
|
|
func unregisterWindow(for sessionID: String) {
|
|
mapLock.withLock {
|
|
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
|
|
logger.info("Unregistered window for session: \(sessionID)")
|
|
}
|
|
sessionsOpenedByUs.remove(sessionID)
|
|
}
|
|
}
|
|
|
|
// MARK: - Window Information
|
|
|
|
/// Gets the window information for a specific session.
|
|
func windowInfo(for sessionID: String) -> WindowInfo? {
|
|
mapLock.withLock {
|
|
sessionWindowMap[sessionID]
|
|
}
|
|
}
|
|
|
|
/// Gets all tracked windows.
|
|
func allTrackedWindows() -> [WindowInfo] {
|
|
mapLock.withLock {
|
|
Array(sessionWindowMap.values)
|
|
}
|
|
}
|
|
|
|
// MARK: - Window Focusing
|
|
|
|
/// Focuses the terminal window for a specific session.
|
|
func focusWindow(for sessionID: String) {
|
|
guard let windowInfo = windowInfo(for: sessionID) else {
|
|
logger.warning("No window registered for session: \(sessionID)")
|
|
return
|
|
}
|
|
|
|
logger.info("Focusing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue)")
|
|
|
|
// Check permissions before attempting to focus
|
|
guard permissionChecker.checkPermissions() else {
|
|
return
|
|
}
|
|
|
|
// Delegate to the window focuser
|
|
windowFocuser.focusWindow(windowInfo)
|
|
}
|
|
|
|
// MARK: - Window Closing
|
|
|
|
/// Closes the terminal window for a specific session if it was opened by VibeTunnel.
|
|
///
|
|
/// This method implements a key feature where terminal windows are automatically closed
|
|
/// when their associated sessions are terminated, but ONLY if VibeTunnel opened them.
|
|
/// This prevents the common issue where killing a process leaves empty terminal windows.
|
|
///
|
|
/// The method checks if:
|
|
/// 1. The session was opened by VibeTunnel (exists in `sessionsOpenedByUs`)
|
|
/// 2. We can find the window using dynamic lookup (process tree traversal)
|
|
/// 3. We can close via Accessibility API (PID-based) or AppleScript
|
|
///
|
|
/// - Parameter sessionID: The ID of the session whose window should be closed
|
|
/// - Returns: `true` if the window was successfully closed, `false` otherwise
|
|
///
|
|
/// - Note: This is called automatically by `SessionService.terminateSession()`
|
|
/// after the server confirms the process has been killed.
|
|
///
|
|
/// Example scenarios:
|
|
/// - ✅ User runs `claude` command via UI → Window closes when session killed
|
|
/// - ✅ User runs long process via UI → Window closes when session killed
|
|
/// - ❌ User attaches existing terminal via `vt` → Window NOT closed
|
|
/// - ❌ User manually opens terminal → Window NOT closed
|
|
@discardableResult
|
|
func closeWindowIfOpenedByUs(for sessionID: String) -> Bool {
|
|
// Check if we opened this window
|
|
let wasOpenedByUs = mapLock.withLock {
|
|
sessionsOpenedByUs.contains(sessionID)
|
|
}
|
|
|
|
guard wasOpenedByUs else {
|
|
logger.info("Session \(sessionID) was not opened by VibeTunnel, not closing window")
|
|
return false
|
|
}
|
|
|
|
// Use dynamic lookup to find the window
|
|
// This is more reliable than stored references which can become stale
|
|
guard let sessionInfo = getSessionInfo(for: sessionID) else {
|
|
logger.warning("No session info found for session: \(sessionID)")
|
|
unregisterWindow(for: sessionID)
|
|
return false
|
|
}
|
|
|
|
guard let windowInfo = findWindowForSession(sessionID, sessionInfo: sessionInfo) else {
|
|
logger.warning("Could not find window for session \(sessionID) - it may have been closed already")
|
|
// Clean up tracking since window is gone
|
|
unregisterWindow(for: sessionID)
|
|
return false
|
|
}
|
|
|
|
logger.info("Closing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue)")
|
|
|
|
// Generate and execute AppleScript to close the window
|
|
let closeScript = generateCloseWindowScript(for: windowInfo)
|
|
do {
|
|
try AppleScriptExecutor.shared.execute(closeScript)
|
|
logger.info("Successfully closed window for session: \(sessionID)")
|
|
|
|
// Clean up tracking
|
|
unregisterWindow(for: sessionID)
|
|
return true
|
|
} catch {
|
|
logger.error("Failed to close window for session \(sessionID): \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Generates AppleScript to close a specific terminal window.
|
|
///
|
|
/// This method creates terminal-specific AppleScript commands to close windows.
|
|
/// Uses window IDs from dynamic lookup rather than stored tab references,
|
|
/// making it robust against tab reordering and window manipulation.
|
|
///
|
|
/// - **Terminal.app**: Uses window ID to close the entire window
|
|
/// - `saving no` prevents save dialogs
|
|
/// - Closes all tabs in the window
|
|
///
|
|
/// - **iTerm2**: Uses window ID with robust matching
|
|
/// - Iterates through windows to find exact match
|
|
/// - Closes entire window
|
|
///
|
|
/// - **Ghostty**: Uses standard AppleScript window closing
|
|
/// - Directly closes window by ID
|
|
/// - Supports modern window management
|
|
///
|
|
/// - **Other terminals**: Not supported as they don't provide reliable window IDs
|
|
///
|
|
/// - Parameter windowInfo: Window information from dynamic lookup
|
|
/// - Returns: AppleScript string to close the window, or empty string if unsupported
|
|
///
|
|
/// - Note: All scripts include error handling to gracefully handle already-closed windows
|
|
private func generateCloseWindowScript(for windowInfo: WindowInfo) -> String {
|
|
switch windowInfo.terminalApp {
|
|
case .terminal:
|
|
// Use window ID to close - more reliable than tab references
|
|
return """
|
|
tell application "Terminal"
|
|
try
|
|
close (first window whose id is \(windowInfo.windowID)) saving no
|
|
on error
|
|
-- Window might already be closed
|
|
end try
|
|
end tell
|
|
"""
|
|
|
|
case .iTerm2:
|
|
// For iTerm2, close the window by matching against all windows
|
|
// iTerm2's window IDs can be tricky, so we use a more robust approach
|
|
return """
|
|
tell application "iTerm2"
|
|
try
|
|
set targetWindows to (windows)
|
|
repeat with w in targetWindows
|
|
try
|
|
if id of w is \(windowInfo.windowID) then
|
|
close w
|
|
exit repeat
|
|
end if
|
|
end try
|
|
end repeat
|
|
on error
|
|
-- Window might already be closed
|
|
end try
|
|
end tell
|
|
"""
|
|
|
|
case .ghostty:
|
|
// Ghostty supports standard AppleScript window operations
|
|
// Note: Ghostty uses lowercase "ghostty" in System Events
|
|
return """
|
|
tell application "ghostty"
|
|
try
|
|
close (first window whose id is \(windowInfo.windowID))
|
|
on error
|
|
-- Window might already be closed
|
|
end try
|
|
end tell
|
|
"""
|
|
|
|
default:
|
|
// For other terminals, we don't have reliable window closing
|
|
logger.warning("Cannot close window for \(windowInfo.terminalApp.rawValue) - terminal not supported")
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Permission Management
|
|
|
|
/// Check if we have the required permissions.
|
|
func checkPermissions() -> Bool {
|
|
permissionChecker.checkPermissions()
|
|
}
|
|
|
|
/// Request accessibility permissions.
|
|
func requestPermissions() {
|
|
permissionChecker.requestPermissions()
|
|
}
|
|
|
|
// MARK: - Session Updates
|
|
|
|
/// Updates window tracking based on current sessions.
|
|
/// This method is called periodically to:
|
|
/// 1. Remove windows for sessions that no longer exist
|
|
/// 2. Try to find windows for ALL sessions without registered windows
|
|
func updateFromSessions(_ sessions: [ServerSessionInfo]) {
|
|
let sessionIDs = Set(sessions.map(\.id))
|
|
|
|
// Remove windows for sessions that no longer exist
|
|
mapLock.withLock {
|
|
let trackedSessions = Set(sessionWindowMap.keys)
|
|
let sessionsToRemove = trackedSessions.subtracting(sessionIDs)
|
|
|
|
for sessionID in sessionsToRemove {
|
|
if sessionWindowMap.removeValue(forKey: sessionID) != nil {
|
|
logger.info("Removed window tracking for terminated session: \(sessionID)")
|
|
}
|
|
// Also clean up the opened-by-us tracking
|
|
sessionsOpenedByUs.remove(sessionID)
|
|
}
|
|
}
|
|
|
|
// Check for sessions that have exited and close their windows if we opened them
|
|
for session in sessions where session.status == "exited" {
|
|
// Only close windows that we opened (not external vt attachments)
|
|
if sessionsOpenedByUs.contains(session.id) {
|
|
logger.info("Session \(session.id) has exited naturally, closing its window")
|
|
_ = closeWindowIfOpenedByUs(for: session.id)
|
|
}
|
|
}
|
|
|
|
// For ALL sessions without registered windows, try to find them
|
|
// This handles:
|
|
// 1. Sessions attached via `vt` command
|
|
// 2. Sessions spawned through the app but window registration failed
|
|
// 3. Any other session that has a terminal window
|
|
for session in sessions where session.isRunning {
|
|
if windowInfo(for: session.id) == nil {
|
|
logger.debug("Session \(session.id) has no window registered, attempting to find it...")
|
|
|
|
// Try to find the window for this session
|
|
if let foundWindow = findWindowForSession(session.id, sessionInfo: session) {
|
|
mapLock.withLock {
|
|
sessionWindowMap[session.id] = foundWindow
|
|
}
|
|
logger
|
|
.info(
|
|
"Found and registered window for session: \(session.id)"
|
|
)
|
|
} else {
|
|
logger.debug("Could not find window for session: \(session.id)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Finds a window for a specific terminal and session.
|
|
private func findWindow(
|
|
for terminal: Terminal,
|
|
sessionID: String,
|
|
tabReference: String?,
|
|
tabID: String?
|
|
)
|
|
-> WindowInfo?
|
|
{
|
|
let allWindows = WindowEnumerator.getAllTerminalWindows()
|
|
let sessionInfo = getSessionInfo(for: sessionID)
|
|
|
|
if let window = windowMatcher.findWindow(
|
|
for: terminal,
|
|
sessionID: sessionID,
|
|
sessionInfo: sessionInfo,
|
|
tabReference: tabReference,
|
|
tabID: tabID,
|
|
terminalWindows: allWindows
|
|
) {
|
|
return createWindowInfo(
|
|
from: window,
|
|
sessionID: sessionID,
|
|
terminal: terminal,
|
|
tabReference: tabReference,
|
|
tabID: tabID
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Helper to create WindowInfo from a found window
|
|
private func createWindowInfo(
|
|
from window: WindowInfo,
|
|
sessionID: String,
|
|
terminal: Terminal,
|
|
tabReference: String?,
|
|
tabID: String?
|
|
)
|
|
-> WindowInfo
|
|
{
|
|
WindowInfo(
|
|
windowID: window.windowID,
|
|
ownerPID: window.ownerPID,
|
|
terminalApp: terminal,
|
|
sessionID: sessionID,
|
|
createdAt: Date(),
|
|
tabReference: tabReference,
|
|
tabID: tabID,
|
|
bounds: window.bounds,
|
|
title: window.title
|
|
)
|
|
}
|
|
|
|
/// Get session info from SessionMonitor
|
|
private func getSessionInfo(for sessionID: String) -> ServerSessionInfo? {
|
|
// Access SessionMonitor to get session details
|
|
// This is safe because both are @MainActor
|
|
SessionMonitor.shared.sessions[sessionID]
|
|
}
|
|
|
|
/// Finds a terminal window for a session that was attached via `vt`.
|
|
private func findWindowForSession(_ sessionID: String, sessionInfo: ServerSessionInfo) -> WindowInfo? {
|
|
let allWindows = WindowEnumerator.getAllTerminalWindows()
|
|
|
|
if let window = windowMatcher
|
|
.findWindowForSession(sessionID, sessionInfo: sessionInfo, allWindows: allWindows)
|
|
{
|
|
return WindowInfo(
|
|
windowID: window.windowID,
|
|
ownerPID: window.ownerPID,
|
|
terminalApp: window.terminalApp,
|
|
sessionID: sessionID,
|
|
createdAt: Date(),
|
|
tabReference: nil,
|
|
tabID: nil,
|
|
bounds: window.bounds,
|
|
title: window.title
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Scans for a terminal window containing a specific session.
|
|
/// This is used for sessions attached via `vt` that weren't launched through our app.
|
|
private func scanForSession(_ sessionID: String) async {
|
|
logger.info("Scanning for window containing session: \(sessionID)")
|
|
|
|
// Get session info to match by working directory
|
|
guard let sessionInfo = getSessionInfo(for: sessionID) else {
|
|
logger.warning("No session info found for session: \(sessionID)")
|
|
return
|
|
}
|
|
|
|
if let foundWindow = findWindowForSession(sessionID, sessionInfo: sessionInfo) {
|
|
mapLock.withLock {
|
|
sessionWindowMap[sessionID] = foundWindow
|
|
}
|
|
logger.info("Successfully found and registered window for session \(sessionID) during scan")
|
|
} else {
|
|
logger.warning("Could not find window for session \(sessionID) during scan")
|
|
}
|
|
}
|
|
}
|