import AppKit import Foundation struct AppMatch: Sendable { let app: NSRunningApplication let score: Double let matchType: String } final class ApplicationFinder: Sendable { static func findApplication(identifier: String) throws(ApplicationError) -> NSRunningApplication { // Logger.shared.debug("Searching for application: \(identifier)") // In CI environment, throw not found to avoid accessing NSWorkspace if ProcessInfo.processInfo.environment["CI"] == "true" { throw ApplicationError.notFound(identifier) } let runningApps = NSWorkspace.shared.runningApplications // Check for exact bundle ID match first if let exactMatch = runningApps.first(where: { $0.bundleIdentifier == identifier }) { // Logger.shared.debug("Found exact bundle ID match: \(exactMatch.localizedName ?? "Unknown")") return exactMatch } // Find all possible matches let allMatches = findAllMatches(for: identifier, in: runningApps) // Filter out browser helpers for common browser searches let matches = filterBrowserHelpers(matches: allMatches, identifier: identifier) // Get unique matches let uniqueMatches = removeDuplicateMatches(from: matches) // Handle results return try processMatchResults(uniqueMatches, identifier: identifier, runningApps: runningApps) } private static func findAllMatches(for identifier: String, in apps: [NSRunningApplication]) -> [AppMatch] { var matches: [AppMatch] = [] let lowerIdentifier = identifier.lowercased() for app in apps { // Check exact name match if let appName = app.localizedName { if appName.lowercased() == lowerIdentifier { matches.append(AppMatch(app: app, score: 1.0, matchType: "exact_name")) continue } // Check partial name matches matches.append(contentsOf: findNameMatches(app: app, appName: appName, identifier: lowerIdentifier)) } // Check bundle ID matches if let bundleId = app.bundleIdentifier, bundleId.lowercased().contains(lowerIdentifier) { let score = Double(lowerIdentifier.count) / Double(bundleId.count) * 0.6 matches.append(AppMatch(app: app, score: score, matchType: "bundle_contains")) } } return matches.sorted { $0.score > $1.score } } private static func findNameMatches(app: NSRunningApplication, appName: String, identifier: String) -> [AppMatch] { var matches: [AppMatch] = [] let lowerAppName = appName.lowercased() if lowerAppName.hasPrefix(identifier) { let score = Double(identifier.count) / Double(lowerAppName.count) matches.append(AppMatch(app: app, score: score, matchType: "prefix")) } else if lowerAppName.contains(identifier) { let score = Double(identifier.count) / Double(lowerAppName.count) * 0.8 matches.append(AppMatch(app: app, score: score, matchType: "contains")) } else { // Try fuzzy matching if no direct match matches.append(contentsOf: findFuzzyMatches(app: app, appName: appName, identifier: identifier)) } return matches } private static func findFuzzyMatches(app: NSRunningApplication, appName: String, identifier: String) -> [AppMatch] { var matches: [AppMatch] = [] let lowerAppName = appName.lowercased() // Try fuzzy matching against the full app name let fullNameSimilarity = calculateStringSimilarity(lowerAppName, identifier) if fullNameSimilarity >= 0.7 { let score = fullNameSimilarity * 0.9 matches.append(AppMatch(app: app, score: score, matchType: "fuzzy")) return matches // Return early if we found a good match } // For multi-word app names, also try fuzzy matching against individual words let words = lowerAppName.split(separator: " ").map(String.init) for (index, word) in words.enumerated() { let wordSimilarity = calculateStringSimilarity(word, identifier) if wordSimilarity >= 0.65 { // Score based on word similarity but reduced for partial matches // Give higher score to matches on the first word (main app name) let positionMultiplier = index == 0 ? 0.85 : 0.75 // Reduce score for helper/service processes var systemPenalty = 1.0 if lowerAppName.contains("helper") { systemPenalty *= 0.8 } if lowerAppName.contains("service") || lowerAppName.contains("theme") { systemPenalty *= 0.7 } let score = wordSimilarity * positionMultiplier * systemPenalty matches.append(AppMatch(app: app, score: score, matchType: "fuzzy_word")) break // Only match first suitable word } } return matches } private static func calculateStringSimilarity(_ str1: String, _ str2: String) -> Double { // Only consider strings with reasonable length differences let lengthDiff = abs(str1.count - str2.count) guard lengthDiff <= 3 else { return 0.0 } let distance = levenshteinDistance(str1, str2) let maxLength = max(str1.count, str2.count) // Calculate similarity (1.0 = identical, 0.0 = completely different) return 1.0 - (Double(distance) / Double(maxLength)) } private static func levenshteinDistance(_ str1: String, _ str2: String) -> Int { let chars1 = Array(str1) let chars2 = Array(str2) let length1 = chars1.count let length2 = chars2.count if length1 == 0 { return length2 } if length2 == 0 { return length1 } var matrix = Array(repeating: Array(repeating: 0, count: length2 + 1), count: length1 + 1) for idx1 in 0...length1 { matrix[idx1][0] = idx1 } for idx2 in 0...length2 { matrix[0][idx2] = idx2 } for idx1 in 1...length1 { for idx2 in 1...length2 { let cost = chars1[idx1 - 1] == chars2[idx2 - 1] ? 0 : 1 matrix[idx1][idx2] = min( matrix[idx1 - 1][idx2] + 1, // deletion matrix[idx1][idx2 - 1] + 1, // insertion matrix[idx1 - 1][idx2 - 1] + cost // substitution ) } } return matrix[length1][length2] } private static func removeDuplicateMatches(from matches: [AppMatch]) -> [AppMatch] { var uniqueMatches: [AppMatch] = [] var seenPIDs: Set = [] for match in matches where !seenPIDs.contains(match.app.processIdentifier) { uniqueMatches.append(match) seenPIDs.insert(match.app.processIdentifier) } return uniqueMatches } private static func processMatchResults( _ matches: [AppMatch], identifier: String, runningApps: [NSRunningApplication] ) throws(ApplicationError) -> NSRunningApplication { guard !matches.isEmpty else { // Provide browser-specific error messages let browserIdentifiers = ["chrome", "safari", "firefox", "edge", "brave", "arc", "opera"] let lowerIdentifier = identifier.lowercased() if browserIdentifiers.contains(lowerIdentifier) { // Logger.shared.error("\(identifier.capitalized) browser is not running or not found") } else { // Logger.shared.error("No applications found matching: \(identifier)") } // Find similar app names using fuzzy matching let suggestions = findSimilarApplications(identifier: identifier, from: runningApps) if !suggestions.isEmpty { // Logger.shared.debug("Did you mean: \(suggestions.joined(separator: ", "))?") } throw ApplicationError.notFound(identifier) } // Check for ambiguous matches let topScore = matches[0].score // Use a smaller threshold for fuzzy matches to avoid ambiguity let threshold = matches[0].matchType.contains("fuzzy") ? 0.05 : 0.1 let topMatches = matches.filter { abs($0.score - topScore) < threshold } if topMatches.count > 1 { handleAmbiguousMatches(topMatches, identifier: identifier) throw ApplicationError.ambiguous(identifier, topMatches.map(\.app)) } let bestMatch = matches[0] // Logger.shared.debug( // "Found application: \(bestMatch.app.localizedName ?? "Unknown") " + // "(score: \(bestMatch.score), type: \(bestMatch.matchType))" // ) return bestMatch.app } private static func handleAmbiguousMatches(_ matches: [AppMatch], identifier: String) { let matchDescriptions = matches.map { match in "\(match.app.localizedName ?? "Unknown") (\(match.app.bundleIdentifier ?? "unknown.bundle"))" } Logger.shared.error("Ambiguous application identifier: \(identifier)") Logger.shared.error("Matches found: \(matchDescriptions.joined(separator: ", "))") } private static func findSimilarApplications(identifier: String, from apps: [NSRunningApplication]) -> [String] { var suggestions: [(name: String, score: Double)] = [] let lowerIdentifier = identifier.lowercased() for app in apps { guard let appName = app.localizedName else { continue } let lowerAppName = appName.lowercased() // Try full name similarity let fullNameSimilarity = calculateStringSimilarity(lowerAppName, lowerIdentifier) if fullNameSimilarity >= 0.6 && fullNameSimilarity < 1.0 { suggestions.append((name: appName, score: fullNameSimilarity)) continue } // For multi-word app names, also check individual words let words = lowerAppName.split(separator: " ").map(String.init) for word in words { let wordSimilarity = calculateStringSimilarity(word, lowerIdentifier) if wordSimilarity >= 0.6 && wordSimilarity < 1.0 { // Reduce score slightly for word matches vs full name matches suggestions.append((name: appName, score: wordSimilarity * 0.9)) break // Only match first suitable word } } } // Sort by similarity and take top 3 suggestions return suggestions .sorted { $0.score > $1.score } .prefix(3) .map(\.name) } static func getAllRunningApplications() -> [ApplicationInfo] { // Logger.shared.debug("Retrieving all running applications") // In CI environment, return empty array to avoid accessing NSWorkspace if ProcessInfo.processInfo.environment["CI"] == "true" { return [] } let runningApps = NSWorkspace.shared.runningApplications var result: [ApplicationInfo] = [] for app in runningApps { // Skip background-only apps without a name guard let appName = app.localizedName, !appName.isEmpty else { continue } // Count windows for this app let windowCount = countWindowsForApp(pid: app.processIdentifier) // Only include applications that have one or more windows. guard windowCount > 0 else { continue } let appInfo = ApplicationInfo( app_name: appName, bundle_id: app.bundleIdentifier ?? "", pid: app.processIdentifier, is_active: app.isActive, window_count: windowCount ) result.append(appInfo) } // Sort by name for consistent output result.sort { $0.app_name.lowercased() < $1.app_name.lowercased() } // Logger.shared.debug("Found \(result.count) running applications") return result } private static func countWindowsForApp(pid: pid_t) -> Int { let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { return 0 } var count = 0 for windowInfo in windowList { if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32, windowPID == pid { count += 1 } } return count } private static func filterBrowserHelpers(matches: [AppMatch], identifier: String) -> [AppMatch] { // Define common browser identifiers that should filter out helpers let browserIdentifiers = ["chrome", "safari", "firefox", "edge", "brave", "arc", "opera"] let lowerIdentifier = identifier.lowercased() // Check if the search is for a common browser guard browserIdentifiers.contains(lowerIdentifier) else { return matches // No filtering for non-browser searches } // Logger.shared.debug("Filtering browser helpers for '\(identifier)' search") // Filter out helper processes for browser searches let filteredMatches = matches.filter { match in guard let appName = match.app.localizedName?.lowercased() else { return true } // Exclude obvious helper processes let isHelper = appName.contains("helper") || appName.contains("renderer") || appName.contains("utility") || appName.contains("plugin") || appName.contains("service") || appName.contains("crashpad") || appName.contains("gpu") || appName.contains("background") if isHelper { // Logger.shared.debug("Filtering out helper process: \(appName)") return false } return true } // If we filtered out all matches, return the original matches to avoid "not found" errors // But log a warning about this case if filteredMatches.isEmpty && !matches.isEmpty { // Logger.shared.debug("All matches were filtered as helpers, returning original matches to avoid 'not // found' error") return matches } // Logger.shared.debug("After browser helper filtering: \(filteredMatches.count) matches remaining") return filteredMatches } } enum ApplicationError: Error, Sendable { case notFound(String) case ambiguous(String, [NSRunningApplication]) }