| | |
| | | // MARK: - App Scanner |
| | | |
| | | enum AppIndexer { |
| | | private struct SearchPathSignature: Equatable { |
| | | let path: String |
| | | let exists: Bool |
| | | let contentModificationTime: TimeInterval? |
| | | } |
| | | |
| | | private static let scanCacheLock = NSLock() |
| | | private static var cachedScanApps: [AppInfo]? = nil |
| | | private static var cachedScanAt: Date? = nil |
| | | private static var cachedSearchPathSignature: [SearchPathSignature]? = nil |
| | | private static let scanCacheTTL: TimeInterval = 1800 |
| | | |
| | | static let searchPaths: [URL] = [ |
| | | URL(fileURLWithPath: "/Applications"), |
| | | URL(fileURLWithPath: "/System/Applications"), |
| | | URL(fileURLWithPath: "/System/Library/CoreServices/Applications"), |
| | | URL(fileURLWithPath: "/System/Cryptexes/App/System/Applications"), |
| | | URL(fileURLWithPath: "/System/Volumes/Preboot/Cryptexes/App/System/Applications"), |
| | | FileManager.default.homeDirectoryForCurrentUser |
| | |
| | | |
| | | private static let systemAppPathPrefixes = [ |
| | | "/System/Applications/", |
| | | "/System/Library/CoreServices/Applications/", |
| | | "/System/Cryptexes/App/System/Applications/", |
| | | "/System/Volumes/Preboot/Cryptexes/App/System/Applications/" |
| | | ] |
| | |
| | | |
| | | /// Scan all standard locations. Tags are annotated from TagDatabase by the caller. |
| | | static func scan(useCache: Bool = true) -> [AppInfo] { |
| | | if useCache, let cached = cachedScanIfFresh() { |
| | | let searchPathSignature = currentSearchPathSignature() |
| | | if useCache, let cached = cachedScanIfFresh(matching: searchPathSignature) { |
| | | return cached |
| | | } |
| | | |
| | | let apps = performScan() |
| | | updateScanCache(apps) |
| | | updateScanCache(apps, searchPathSignature: searchPathSignature) |
| | | return apps |
| | | } |
| | | |
| | |
| | | scanCacheLock.lock() |
| | | cachedScanApps = nil |
| | | cachedScanAt = nil |
| | | cachedSearchPathSignature = nil |
| | | scanCacheLock.unlock() |
| | | } |
| | | |
| | | private static func cachedScanIfFresh() -> [AppInfo]? { |
| | | static func shouldRefreshForSearchPathChanges() -> Bool { |
| | | scanCacheLock.lock() |
| | | let hasCachedApps = cachedScanApps != nil |
| | | let cachedSignature = cachedSearchPathSignature |
| | | scanCacheLock.unlock() |
| | | |
| | | guard hasCachedApps else { return true } |
| | | return cachedSignature != currentSearchPathSignature() |
| | | } |
| | | |
| | | private static func cachedScanIfFresh(matching searchPathSignature: [SearchPathSignature]) -> [AppInfo]? { |
| | | scanCacheLock.lock() |
| | | defer { scanCacheLock.unlock() } |
| | | |
| | | guard let cachedScanApps, |
| | | let cachedScanAt, |
| | | cachedSearchPathSignature == searchPathSignature, |
| | | Date().timeIntervalSince(cachedScanAt) < scanCacheTTL |
| | | else { return nil } |
| | | return cachedScanApps |
| | | } |
| | | |
| | | private static func updateScanCache(_ apps: [AppInfo]) { |
| | | private static func updateScanCache(_ apps: [AppInfo], searchPathSignature: [SearchPathSignature]) { |
| | | scanCacheLock.lock() |
| | | cachedScanApps = apps |
| | | cachedScanAt = Date() |
| | | cachedSearchPathSignature = searchPathSignature |
| | | scanCacheLock.unlock() |
| | | } |
| | | |
| | | private static func currentSearchPathSignature() -> [SearchPathSignature] { |
| | | searchPaths.map { url in |
| | | var isDirectory: ObjCBool = false |
| | | let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) |
| | | guard exists, isDirectory.boolValue else { |
| | | return SearchPathSignature( |
| | | path: url.path, |
| | | exists: false, |
| | | contentModificationTime: nil |
| | | ) |
| | | } |
| | | |
| | | let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) |
| | | return SearchPathSignature( |
| | | path: url.path, |
| | | exists: true, |
| | | contentModificationTime: values?.contentModificationDate?.timeIntervalSinceReferenceDate |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private static func performScan() -> [AppInfo] { |
| | |
| | | guard !isNestedInsideAppBundle(resolvedURL) else { return } |
| | | guard seenResolvedPaths.insert(resolvedURL.path).inserted else { return } |
| | | |
| | | let bundle = Bundle(url: displayURL) ?? Bundle(url: resolvedURL) |
| | | let bundleId = bundle?.bundleIdentifier |
| | | guard !isTagLauncherBundle(bundleId) else { return } |
| | | |
| | | let name = displayURL.deletingPathExtension().lastPathComponent |
| | | let icon = NSWorkspace.shared.icon(forFile: displayURL.path) |
| | | icon.size = NSSize(width: 96, height: 96) |
| | | |
| | | let bundle = Bundle(url: displayURL) ?? Bundle(url: resolvedURL) |
| | | let bundleId = bundle?.bundleIdentifier |
| | | let localizedNamesByLanguage = localizedAppNameMap( |
| | | bundle: bundle, |
| | | fallbackName: name |
| | |
| | | localizedNamesByLanguage: localizedNamesByLanguage, |
| | | icon: icon |
| | | )) |
| | | } |
| | | |
| | | private static func isTagLauncherBundle(_ bundleIdentifier: String?) -> Bool { |
| | | bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame |
| | | } |
| | | |
| | | private static func localizedAppNameMap( |
| | |
| | | private static func deduplicationIdentity(for app: AppInfo) -> String { |
| | | if let bundleIdentifier = app.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), |
| | | !bundleIdentifier.isEmpty { |
| | | return "bundle:\(bundleIdentifier.lowercased())" |
| | | return "bundle:\(bundleIdentifier.lowercased())|name:\(normalizedAppName(app.name))" |
| | | } |
| | | return "name:\(normalizedAppName(app.name))" |
| | | } |