| | |
| | | return localizedName |
| | | } |
| | | } |
| | | if let localizedName = localizedNames.first, !localizedName.isEmpty { |
| | | return localizedName |
| | | } |
| | | return name |
| | | } |
| | | |
| | |
| | | // 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] { |
| | |
| | | let name = displayURL.deletingPathExtension().lastPathComponent |
| | | let icon = NSWorkspace.shared.icon(forFile: displayURL.path) |
| | | icon.size = NSSize(width: 96, height: 96) |
| | | let localizedNamesByLanguage = localizedAppNameMap( |
| | | var localizedNamesByLanguage = localizedAppNameMap( |
| | | bundle: bundle, |
| | | fallbackName: name |
| | | ) |
| | | if let bundleId { |
| | | for (languageCode, displayName) in AppleDefaultAppCatalog.localizedNamesByLanguage( |
| | | forBundleIdentifier: bundleId |
| | | ) { |
| | | if localizedNamesByLanguage[languageCode]?.isEmpty != false { |
| | | localizedNamesByLanguage[languageCode] = displayName |
| | | } |
| | | } |
| | | } |
| | | let localizedNames = localizedAppNames( |
| | | for: displayURL, |
| | | fallbackName: name, |
| | |
| | | localizedNamesByLanguage: [String: String] |
| | | ) -> [String] { |
| | | var values = L10n.supported.map { localizedNamesByLanguage[$0.code] } |
| | | values.append(resourceLocalizedName(for: appURL)) |
| | | values.append(spotlightDisplayName(for: appURL)) |
| | | values.append(FileManager.default.displayName(atPath: appURL.path).replacingOccurrences(of: ".app", with: "")) |
| | | |
| | |
| | | return trimmed.isEmpty ? nil : trimmed |
| | | } |
| | | |
| | | private static func resourceLocalizedName(for appURL: URL) -> String? { |
| | | guard let value = try? appURL.resourceValues(forKeys: [.localizedNameKey]).localizedName else { |
| | | return nil |
| | | } |
| | | |
| | | let trimmed = value.replacingOccurrences(of: ".app", with: "").trimmingCharacters(in: .whitespacesAndNewlines) |
| | | return trimmed.isEmpty ? nil : trimmed |
| | | } |
| | | |
| | | private static func uniqueLocalizedNames(_ values: [String?], excluding excludedValue: String) -> [String] { |
| | | let normalizedExcluded = normalizedLocalizedName(excludedValue) |
| | | var seen = Set<String>() |
| | |
| | | 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))" |
| | | } |
| | |
| | | struct AppNoteMetadata: Codable, Equatable { |
| | | var origin: AppNoteOrigin |
| | | var catalog: SmartDefaultNoteProvenance? = nil |
| | | var apple: SmartDefaultNoteProvenance? = nil |
| | | var noteFingerprint: String |
| | | } |
| | | |
| | |
| | | for path in newPaths { |
| | | if let app = appsByPath[path], |
| | | app.isAppleApp, |
| | | AppleDefaultAppNotes.isFamiliarAppleApp(app) { |
| | | AppleDefaultAppCatalog.isFamiliarAppleApp(app) { |
| | | continue |
| | | } |
| | | uncommonPaths.insert(path) |
| | |
| | | |
| | | for app in apps where app.isAppleApp { |
| | | let path = app.path.path |
| | | if store.appNotes[path]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { |
| | | |
| | | let currentNote = store.appNotes[path]?.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | let existingMetadata = store.appNoteMetadata[path] |
| | | if existingMetadata?.origin == .manual { |
| | | continue |
| | | } |
| | | |
| | | guard let note = AppleDefaultAppNotes.note(for: app) else { continue } |
| | | let limited = String(note.prefix(TagDatabase.maxAppNoteLength)) |
| | | store.appNotes[path] = limited |
| | | if let currentNote, !currentNote.isEmpty { |
| | | let currentFingerprint = TagDatabase.noteFingerprint(currentNote) |
| | | let currentMatchesDefault = existingMetadata?.origin == .appleDefault |
| | | && currentFingerprint == existingMetadata?.noteFingerprint |
| | | guard currentMatchesDefault else { |
| | | continue |
| | | } |
| | | } |
| | | |
| | | guard let defaultNote = AppleDefaultAppCatalog.defaultNote(for: app), |
| | | defaultNote.note != currentNote |
| | | else { continue } |
| | | |
| | | store.appNotes[path] = defaultNote.note |
| | | store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata( |
| | | origin: .appleDefault, |
| | | noteFingerprint: TagDatabase.noteFingerprint(limited) |
| | | apple: defaultNote.provenance, |
| | | noteFingerprint: defaultNote.provenance.noteFingerprint |
| | | ) |
| | | changed = true |
| | | } |
| | |
| | | var uncommonPaths = Set(store.uncommonAppPaths) |
| | | var changed = false |
| | | |
| | | for app in apps where app.isAppleApp && !AppleDefaultAppNotes.isFamiliarAppleApp(app) { |
| | | for app in apps where app.isAppleApp && !AppleDefaultAppCatalog.isFamiliarAppleApp(app) { |
| | | let path = app.path.path |
| | | if uncommonPaths.insert(path).inserted { |
| | | changed = true |
| | |
| | | let limited = String(trimmed.prefix(TagDatabase.maxAppNoteLength)) |
| | | if limited.isEmpty { |
| | | store.appNotes.removeValue(forKey: path) |
| | | store.appNoteMetadata.removeValue(forKey: path) |
| | | store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata( |
| | | origin: .manual, |
| | | noteFingerprint: TagDatabase.noteFingerprint("") |
| | | ) |
| | | } else { |
| | | store.appNotes[path] = limited |
| | | store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata( |