Peekaboo/peekaboo-cli/Sources/peekaboo/ApplicationFinder.swift
Peter Steinberger 0bec93e364 Apply SwiftFormat changes for v1.0.0 release
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 20:35:27 +01:00

373 lines
15 KiB
Swift

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<pid_t> = []
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])
}