mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-09 11:55:53 +00:00
464 lines
17 KiB
Swift
464 lines
17 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
|
|
@MainActor
|
|
final class WindowTracker {
|
|
static let shared = WindowTracker()
|
|
|
|
private let logger = Logger(
|
|
subsystem: "sh.vibetunnel.vibetunnel",
|
|
category: "WindowTracker"
|
|
)
|
|
|
|
/// Information about a tracked terminal window
|
|
struct WindowInfo {
|
|
let windowID: CGWindowID
|
|
let ownerPID: pid_t
|
|
let terminalApp: Terminal
|
|
let sessionID: String
|
|
let createdAt: Date
|
|
|
|
// Tab-specific information
|
|
let tabReference: String? // AppleScript reference for Terminal.app tabs
|
|
let tabID: String? // Tab identifier for iTerm2
|
|
|
|
// Window properties from Core Graphics
|
|
let bounds: CGRect?
|
|
let title: String?
|
|
}
|
|
|
|
/// Maps session IDs to their terminal window information
|
|
private var sessionWindowMap: [String: WindowInfo] = [:]
|
|
|
|
/// Lock for thread-safe access to the session map
|
|
private let mapLock = NSLock()
|
|
|
|
private init() {
|
|
logger.info("WindowTracker initialized")
|
|
}
|
|
|
|
// MARK: - Window Registration
|
|
|
|
/// Registers a terminal window for a session.
|
|
/// This should be called after launching a terminal with a session ID.
|
|
func registerWindow(
|
|
for sessionID: String,
|
|
terminalApp: Terminal,
|
|
tabReference: String? = nil,
|
|
tabID: String? = nil
|
|
) {
|
|
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
|
|
|
|
// Give the terminal some time to create the window
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(1.0))
|
|
|
|
// Find the most recently created window for this terminal
|
|
if let windowInfo = findWindow(
|
|
for: terminalApp,
|
|
sessionID: sessionID,
|
|
tabReference: tabReference,
|
|
tabID: tabID
|
|
) {
|
|
mapLock.withLock {
|
|
sessionWindowMap[sessionID] = windowInfo
|
|
}
|
|
logger.info("Successfully registered window \(windowInfo.windowID) for session \(sessionID)")
|
|
} else {
|
|
logger.warning("Could not find window for session \(sessionID)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Window Enumeration
|
|
|
|
/// Gets all terminal windows currently visible on screen.
|
|
static func getAllTerminalWindows() -> [WindowInfo] {
|
|
let options: CGWindowListOption = [.excludeDesktopElements, .optionOnScreenOnly]
|
|
|
|
guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
return windowList.compactMap { windowDict in
|
|
// Extract window properties
|
|
guard let ownerPID = windowDict[kCGWindowOwnerPID as String] as? pid_t,
|
|
let windowID = windowDict[kCGWindowNumber as String] as? CGWindowID,
|
|
let ownerName = windowDict[kCGWindowOwnerName as String] as? String
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
// Check if this is a terminal application
|
|
guard let terminal = Terminal.allCases.first(where: { term in
|
|
// Match by process name or app name
|
|
ownerName == term.processName || ownerName == term.rawValue
|
|
}) else {
|
|
return nil
|
|
}
|
|
|
|
// Get window bounds
|
|
let bounds: CGRect? = if let boundsDict = windowDict[kCGWindowBounds as String] as? [String: CGFloat],
|
|
let x = boundsDict["X"],
|
|
let y = boundsDict["Y"],
|
|
let width = boundsDict["Width"],
|
|
let height = boundsDict["Height"]
|
|
{
|
|
CGRect(x: x, y: y, width: width, height: height)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
// Get window title
|
|
let title = windowDict[kCGWindowName as String] as? String
|
|
|
|
return WindowInfo(
|
|
windowID: windowID,
|
|
ownerPID: ownerPID,
|
|
terminalApp: terminal,
|
|
sessionID: "", // Will be filled when registered
|
|
createdAt: Date(),
|
|
tabReference: nil,
|
|
tabID: nil,
|
|
bounds: bounds,
|
|
title: title
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Finds a window for a specific terminal and session.
|
|
private func findWindow(
|
|
for terminal: Terminal,
|
|
sessionID: String,
|
|
tabReference: String?,
|
|
tabID: String?
|
|
)
|
|
-> WindowInfo?
|
|
{
|
|
let allWindows = Self.getAllTerminalWindows()
|
|
|
|
// Filter windows for the specific terminal
|
|
let terminalWindows = allWindows.filter { $0.terminalApp == terminal }
|
|
|
|
// If we have specific tab information, try to match by title or other properties
|
|
// For now, return the most recently created window (highest window ID)
|
|
guard let latestWindow = terminalWindows.max(by: { $0.windowID < $1.windowID }) else {
|
|
return nil
|
|
}
|
|
|
|
// Create a new WindowInfo with the session information
|
|
return WindowInfo(
|
|
windowID: latestWindow.windowID,
|
|
ownerPID: latestWindow.ownerPID,
|
|
terminalApp: terminal,
|
|
sessionID: sessionID,
|
|
createdAt: Date(),
|
|
tabReference: tabReference,
|
|
tabID: tabID,
|
|
bounds: latestWindow.bounds,
|
|
title: latestWindow.title
|
|
)
|
|
}
|
|
|
|
// MARK: - Window Focus
|
|
|
|
/// Focuses the window associated with a session.
|
|
func focusWindow(for sessionID: String) {
|
|
mapLock.withLock {
|
|
guard let windowInfo = sessionWindowMap[sessionID] else {
|
|
logger.warning("No window found for session: \(sessionID)")
|
|
logger.debug("Available sessions: \(self.sessionWindowMap.keys.joined(separator: ", "))")
|
|
|
|
// Try to scan for the session one more time
|
|
Task {
|
|
await scanForSession(sessionID)
|
|
// Try focusing again after scan
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
self.focusWindow(for: sessionID)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
logger
|
|
.info(
|
|
"Focusing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue), windowID: \(windowInfo.windowID)"
|
|
)
|
|
|
|
switch windowInfo.terminalApp {
|
|
case .terminal:
|
|
focusTerminalAppWindow(windowInfo)
|
|
case .iTerm2:
|
|
focusiTerm2Window(windowInfo)
|
|
default:
|
|
// For other terminals, use standard window focus
|
|
focusWindowUsingAccessibility(windowInfo)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Focuses a Terminal.app window/tab.
|
|
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
|
if let tabRef = windowInfo.tabReference {
|
|
// Use stored tab reference
|
|
let script = """
|
|
tell application "Terminal"
|
|
activate
|
|
\(tabRef)
|
|
end tell
|
|
"""
|
|
|
|
do {
|
|
try AppleScriptExecutor.shared.execute(script)
|
|
logger.info("Focused Terminal.app tab using reference")
|
|
} catch {
|
|
logger.error("Failed to focus Terminal.app tab: \(error)")
|
|
// Fallback to accessibility
|
|
focusWindowUsingAccessibility(windowInfo)
|
|
}
|
|
} else {
|
|
// Fallback to window ID based focusing
|
|
let script = """
|
|
tell application "Terminal"
|
|
activate
|
|
set allWindows to windows
|
|
repeat with w in allWindows
|
|
if id of w is \(windowInfo.windowID) then
|
|
set frontmost of w to true
|
|
exit repeat
|
|
end if
|
|
end repeat
|
|
end tell
|
|
"""
|
|
|
|
do {
|
|
try AppleScriptExecutor.shared.execute(script)
|
|
} catch {
|
|
logger.error("Failed to focus Terminal.app window: \(error)")
|
|
focusWindowUsingAccessibility(windowInfo)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Focuses an iTerm2 window.
|
|
private func focusiTerm2Window(_ windowInfo: WindowInfo) {
|
|
if let windowID = windowInfo.tabID {
|
|
// Use window ID for focusing (stored in tabID for consistency)
|
|
let script = """
|
|
tell application "iTerm2"
|
|
activate
|
|
tell window id "\(windowID)"
|
|
select
|
|
end tell
|
|
end tell
|
|
"""
|
|
|
|
do {
|
|
try AppleScriptExecutor.shared.execute(script)
|
|
logger.info("Focused iTerm2 window using ID")
|
|
} catch {
|
|
logger.error("Failed to focus iTerm2 window: \(error)")
|
|
focusWindowUsingAccessibility(windowInfo)
|
|
}
|
|
} else {
|
|
// Fallback to window focusing
|
|
focusWindowUsingAccessibility(windowInfo)
|
|
}
|
|
}
|
|
|
|
/// Focuses a window using Accessibility APIs.
|
|
private func focusWindowUsingAccessibility(_ windowInfo: WindowInfo) {
|
|
// First bring the application to front
|
|
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
|
|
app.activate()
|
|
logger.info("Activated application with PID: \(windowInfo.ownerPID)")
|
|
}
|
|
|
|
// Use AXUIElement to focus the specific window
|
|
let axApp = AXUIElementCreateApplication(windowInfo.ownerPID)
|
|
|
|
var windowsValue: CFTypeRef?
|
|
let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue)
|
|
|
|
guard result == .success,
|
|
let windows = windowsValue as? [AXUIElement],
|
|
!windows.isEmpty
|
|
else {
|
|
logger.error("Failed to get windows for application")
|
|
return
|
|
}
|
|
|
|
// Try to find the window by comparing window IDs
|
|
for window in windows {
|
|
var windowIDValue: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(window, kAXWindowAttribute as CFString, &windowIDValue) == .success,
|
|
let windowNumber = windowIDValue as? Int,
|
|
windowNumber == windowInfo.windowID
|
|
{
|
|
// Found the matching window, make it main and focused
|
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
|
logger.info("Focused window using Accessibility API")
|
|
return
|
|
}
|
|
}
|
|
|
|
logger.warning("Could not find matching window in AXUIElement list")
|
|
}
|
|
|
|
// MARK: - Direct Permission Checks
|
|
|
|
/// Checks if we have the required permissions for window tracking using direct API calls.
|
|
private func checkPermissionsDirectly() -> Bool {
|
|
// Check for Screen Recording permission (required for CGWindowListCopyWindowInfo)
|
|
let options: CGWindowListOption = [.excludeDesktopElements]
|
|
if let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]],
|
|
!windowList.isEmpty
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Requests the required permissions by opening System Preferences.
|
|
private func requestPermissionsDirectly() {
|
|
logger.info("Requesting Screen Recording permission")
|
|
|
|
// Open System Preferences to Privacy & Security > Screen Recording
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
// MARK: - Session Scanning
|
|
|
|
/// 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 all terminal windows
|
|
let allWindows = Self.getAllTerminalWindows()
|
|
|
|
// Look for windows that might contain this session
|
|
// Sessions typically show their ID in the window title
|
|
for window in allWindows {
|
|
// Check if window title contains session ID
|
|
if let title = window.title,
|
|
title.contains(sessionID) || title.contains("vt") || title.contains("vibetunnel")
|
|
{
|
|
logger.info("Found potential window for session \(sessionID): \(title)")
|
|
|
|
// Create window info for this session
|
|
let windowInfo = WindowInfo(
|
|
windowID: window.windowID,
|
|
ownerPID: window.ownerPID,
|
|
terminalApp: window.terminalApp,
|
|
sessionID: sessionID,
|
|
createdAt: Date(),
|
|
tabReference: nil,
|
|
tabID: nil,
|
|
bounds: window.bounds,
|
|
title: window.title
|
|
)
|
|
|
|
mapLock.withLock {
|
|
sessionWindowMap[sessionID] = windowInfo
|
|
}
|
|
|
|
logger.info("Successfully mapped window \(window.windowID) to session \(sessionID)")
|
|
return
|
|
}
|
|
}
|
|
|
|
logger.debug("Could not find window for session \(sessionID) in \(allWindows.count) terminal windows")
|
|
}
|
|
|
|
// MARK: - Session Monitoring
|
|
|
|
/// Updates the window tracker based on active sessions.
|
|
/// Should be called when SessionMonitor updates.
|
|
func updateFromSessions(_ sessions: [ServerSessionInfo]) {
|
|
mapLock.withLock {
|
|
// Remove windows for sessions that no longer exist
|
|
let activeSessionIDs = Set(sessions.map(\.id))
|
|
sessionWindowMap = sessionWindowMap.filter { activeSessionIDs.contains($0.key) }
|
|
|
|
// Scan for untracked sessions (e.g., attached via vt command)
|
|
for session in sessions where session.isRunning {
|
|
if sessionWindowMap[session.id] == nil {
|
|
// This session isn't tracked yet, try to find its window
|
|
Task {
|
|
await scanForSession(session.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
logger
|
|
.debug(
|
|
"Updated window tracker: \(self.sessionWindowMap.count) active windows, \(sessions.count) total sessions"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Gets the window information for a session.
|
|
func windowInfo(for sessionID: String) -> WindowInfo? {
|
|
mapLock.withLock {
|
|
sessionWindowMap[sessionID]
|
|
}
|
|
}
|
|
|
|
/// Gets all tracked windows.
|
|
func allTrackedWindows() -> [WindowInfo] {
|
|
mapLock.withLock {
|
|
Array(sessionWindowMap.values)
|
|
}
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
/// Checks if we have the necessary permissions for window tracking.
|
|
func checkPermissions() -> Bool {
|
|
// Check Screen Recording permission
|
|
guard SystemPermissionManager.shared.hasPermission(.screenRecording) else {
|
|
logger.warning("Screen Recording permission required for window tracking")
|
|
return false
|
|
}
|
|
|
|
// Check Accessibility permission (for window focusing)
|
|
guard SystemPermissionManager.shared.hasPermission(.accessibility) else {
|
|
logger.warning("Accessibility permission required for window focusing")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/// Requests all necessary permissions for window tracking.
|
|
func requestPermissions() {
|
|
if !SystemPermissionManager.shared.hasPermission(.screenRecording) {
|
|
SystemPermissionManager.shared.requestPermission(.screenRecording)
|
|
}
|
|
|
|
if !SystemPermissionManager.shared.hasPermission(.accessibility) {
|
|
SystemPermissionManager.shared.requestPermission(.accessibility)
|
|
}
|
|
}
|
|
}
|