vibetunnel/mac/VibeTunnel/Core/Services/WindowTracking/WindowMatcher.swift

310 lines
13 KiB
Swift

import AppKit
import Foundation
import OSLog
/// Handles window matching and session-to-window mapping algorithms.
@MainActor
final class WindowMatcher {
private let logger = Logger(
subsystem: BundleIdentifiers.loggerSubsystem,
category: "WindowMatcher"
)
private let processTracker = ProcessTracker()
/// Find a window for a specific terminal and session
func findWindow(
for terminal: Terminal,
sessionID: String,
sessionInfo: ServerSessionInfo?,
tabReference: String?,
tabID: String?,
terminalWindows: [WindowInfo]
)
-> WindowInfo?
{
// Filter windows for the specific terminal
let filteredWindows = terminalWindows.filter { $0.terminalApp == terminal }
// First try to find window by process PID traversal
if let sessionInfo, let sessionPID = sessionInfo.pid {
logger.debug("Attempting to find window by process PID: \(sessionPID)")
// For debugging: log the process tree
processTracker.logProcessTree(for: pid_t(sessionPID))
// Try to find the parent process (shell) that owns this session
if let parentPID = processTracker.getParentProcessID(of: pid_t(sessionPID)) {
logger.debug("Found parent process PID: \(parentPID)")
// Look for windows owned by the parent process
let parentPIDWindows = filteredWindows.filter { window in
window.ownerPID == parentPID
}
if parentPIDWindows.count == 1 {
logger.info("Found single window by parent process match: PID \(parentPID)")
return parentPIDWindows.first
} else if parentPIDWindows.count > 1 {
logger
.info(
"Found \(parentPIDWindows.count) windows for PID \(parentPID), checking session ID in titles"
)
// Multiple windows - try to match by session ID in title
if let matchingWindow = parentPIDWindows.first(where: { window in
window.title?.contains("Session \(sessionID)") ?? false
}) {
logger.info("Found window by session ID '\(sessionID)' in title")
return matchingWindow
}
// If no session ID match, return first window
logger.warning("No window with session ID in title, using first window")
return parentPIDWindows.first
}
// If direct parent match fails, try to find grandparent or higher ancestors
var currentPID = parentPID
var depth = 0
while depth < 10 { // Increased depth for nested shell sessions
if let grandParentPID = processTracker.getParentProcessID(of: currentPID) {
logger.debug("Checking ancestor process PID: \(grandParentPID) at depth \(depth + 2)")
let ancestorPIDWindows = filteredWindows.filter { window in
window.ownerPID == grandParentPID
}
if ancestorPIDWindows.count == 1 {
logger
.info(
"Found single window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)"
)
return ancestorPIDWindows.first
} else if ancestorPIDWindows.count > 1 {
logger
.info(
"Found \(ancestorPIDWindows.count) windows for ancestor PID \(grandParentPID), checking session ID"
)
// Multiple windows - try to match by session ID in title
if let matchingWindow = ancestorPIDWindows.first(where: { window in
window.title?.contains("Session \(sessionID)") ?? false
}) {
logger.info("Found window by session ID '\(sessionID)' in title")
return matchingWindow
}
// If no session ID match, return first window
return ancestorPIDWindows.first
}
currentPID = grandParentPID
depth += 1
} else {
break
}
}
}
}
// Fallback: try to find window by title containing session path or command
if let sessionInfo {
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
// Look for windows whose title contains the directory name
if let matchingWindow = filteredWindows.first(where: { window in
WindowEnumerator.windowTitleContains(window, identifier: dirName) ||
WindowEnumerator.windowTitleContains(window, identifier: workingDir)
}) {
logger.debug("Found window by directory match: \(dirName)")
return matchingWindow
}
}
// For Terminal.app with specific tab reference
if terminal == .terminal, let tabRef = tabReference {
if let windowID = WindowEnumerator.extractWindowID(from: tabRef) {
if let matchingWindow = filteredWindows.first(where: { $0.windowID == windowID }) {
logger.debug("Found Terminal.app window by ID: \(windowID)")
return matchingWindow
}
}
}
// For iTerm2 with tab ID
if terminal == .iTerm2, let tabID {
// Try to match by window title which often includes the window ID
if let matchingWindow = filteredWindows.first(where: { window in
WindowEnumerator.windowTitleContains(window, identifier: tabID)
}) {
logger.debug("Found iTerm2 window by ID in title: \(tabID)")
return matchingWindow
}
}
// Fallback: return the most recently created window (highest window ID)
if let latestWindow = filteredWindows.max(by: { $0.windowID < $1.windowID }) {
logger.debug("Using most recent window as fallback for session: \(sessionID)")
return latestWindow
}
return nil
}
/// Find a terminal window for a session that was attached via `vt`
func findWindowForSession(
_ sessionID: String,
sessionInfo: ServerSessionInfo,
allWindows: [WindowInfo]
)
-> WindowInfo?
{
// First try to find window by process PID traversal
if let sessionPID = sessionInfo.pid {
logger.debug("Scanning for window by process PID: \(sessionPID) for session \(sessionID)")
// Log the process tree for debugging
processTracker.logProcessTree(for: pid_t(sessionPID))
// Try to traverse up the process tree to find a terminal window
var currentPID = pid_t(sessionPID)
var depth = 0
let maxDepth = 20 // Increased depth for deeply nested sessions
while depth < maxDepth {
// Check if any window is owned by this PID
if let matchingWindow = allWindows.first(where: { window in
window.ownerPID == currentPID
}) {
logger.info("Found window by PID \(currentPID) at depth \(depth) for session \(sessionID)")
return matchingWindow
}
// Move up to parent process
if let parentPID = processTracker.getParentProcessID(of: currentPID) {
if parentPID == 0 || parentPID == 1 {
// Reached root process
break
}
currentPID = parentPID
depth += 1
} else {
break
}
}
logger.debug("Process traversal completed at depth \(depth) without finding window")
}
// Fallback: Find by working directory
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
logger.debug("Trying to match by directory: \(dirName) or full path: \(workingDir)")
// Look for windows whose title contains the directory name
if let matchingWindow = allWindows.first(where: { window in
if let title = window.title {
let matches = title.contains(dirName) || title.contains(workingDir)
if matches {
logger.debug("Window title '\(title)' matches directory")
}
return matches
}
return false
}) {
logger.info("Found window by directory match: \(dirName) for session \(sessionID)")
return matchingWindow
}
// Try to match by activity status (for sessions with specific activities)
if let activity = sessionInfo.activityStatus?.specificStatus?.status, !activity.isEmpty {
logger.debug("Trying to match by activity: \(activity)")
if let matchingWindow = allWindows.first(where: { window in
if let title = window.title {
return title.contains(activity)
}
return false
}) {
logger.info("Found window by activity match: \(activity) for session \(sessionID)")
return matchingWindow
}
}
logger.warning("Could not find window for session \(sessionID) after all attempts")
logger.debug("Available windows: \(allWindows.count)")
for (index, window) in allWindows.enumerated() {
logger
.debug(
" Window \(index): PID=\(window.ownerPID), Terminal=\(window.terminalApp.rawValue), Title=\(window.title ?? "<no title>")"
)
}
return nil
}
/// Find matching tab using accessibility APIs
func findMatchingTab(tabs: [AXElement], sessionInfo: ServerSessionInfo?) -> AXElement? {
guard let sessionInfo else { return nil }
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
let sessionID = sessionInfo.id
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
let sessionName = sessionInfo.name
logger.debug("Looking for tab matching session \(sessionID) in \(tabs.count) tabs")
logger.debug(" Working dir: \(workingDir)")
logger.debug(" Dir name: \(dirName)")
logger.debug(" Session name: \(sessionName)")
logger.debug(" Activity: \(activityStatus ?? "none")")
for (index, tab) in tabs.enumerated() {
if let title = tab.title {
logger.debug("Tab \(index) title: \(title)")
// Check for session ID match first (most precise)
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
logger.info("Found tab by session ID match at index \(index)")
return tab
}
// Check for session name match
if !sessionName.isEmpty, title.contains(sessionName) {
logger.info("Found tab by session name match: \(sessionName) at index \(index)")
return tab
}
// Check for activity status match
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
logger.info("Found tab by activity match: \(activity) at index \(index)")
return tab
}
// Check for directory match - be more flexible
let titleLower = title.lowercased()
let dirNameLower = dirName.lowercased()
let workingDirLower = workingDir.lowercased()
if titleLower.contains(dirNameLower) || titleLower.contains(workingDirLower) {
logger.info("Found tab by directory match at index \(index)")
return tab
}
// Check if the tab title ends with the directory name (common pattern)
if title.hasSuffix(dirName) || title.hasSuffix(" - \(dirName)") {
logger.info("Found tab by directory suffix match at index \(index)")
return tab
}
} else {
logger.debug("Tab \(index): Could not get title")
}
}
logger.warning("No matching tab found for session \(sessionID)")
return nil
}
}