mirror of
https://github.com/samsonjs/Peekaboo.git
synced 2026-03-25 09:25:47 +00:00
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
373 lines
15 KiB
Swift
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])
|
|
}
|