| | |
| | | import Foundation |
| | | import AppKit |
| | | import CoreServices |
| | | |
| | | // MARK: - Data Models |
| | | |
| | |
| | | let path: URL |
| | | let tags: [String] |
| | | let bundleIdentifier: String? |
| | | let localizedNames: [String] |
| | | let localizedNamesByLanguage: [String: String] |
| | | let icon: NSImage // Pre-loaded during background scan |
| | | var isUncommon: Bool = false |
| | | var note: String? = nil |
| | | |
| | | var displayName: String { |
| | | localizedDisplayName(for: L10n.currentCode) |
| | | } |
| | | |
| | | func localizedDisplayName(for languageCode: String) -> String { |
| | | let candidates = AppInfo.displayLanguageFallbacks(for: languageCode) |
| | | for code in candidates { |
| | | if let localizedName = localizedNamesByLanguage[code], |
| | | !localizedName.isEmpty { |
| | | return localizedName |
| | | } |
| | | } |
| | | return name |
| | | } |
| | | |
| | | private static func displayLanguageFallbacks(for languageCode: String) -> [String] { |
| | | var candidates = [languageCode] |
| | | switch languageCode { |
| | | case "zh-Hans": |
| | | candidates.append("zh-Hant") |
| | | case "zh-Hant": |
| | | candidates.append("zh-Hans") |
| | | case "nb": |
| | | candidates.append("no") |
| | | case "nn": |
| | | candidates.append("no") |
| | | case "no": |
| | | candidates.append(contentsOf: ["nb", "nn"]) |
| | | default: |
| | | break |
| | | } |
| | | candidates.append("en") |
| | | return uniqueLanguageCodes(candidates) |
| | | } |
| | | |
| | | fileprivate static func uniqueLanguageCodes(_ codes: [String]) -> [String] { |
| | | var seen = Set<String>() |
| | | return codes.filter { seen.insert($0).inserted } |
| | | } |
| | | |
| | | /// True if this is an Apple pre-installed app. |
| | | var isAppleApp: Bool { |
| | |
| | | // MARK: - App Scanner |
| | | |
| | | enum AppIndexer { |
| | | private static let scanCacheLock = NSLock() |
| | | private static var cachedScanApps: [AppInfo]? = nil |
| | | private static var cachedScanAt: Date? = nil |
| | | private static let scanCacheTTL: TimeInterval = 1800 |
| | | |
| | | static let searchPaths: [URL] = [ |
| | | URL(fileURLWithPath: "/Applications"), |
| | |
| | | } |
| | | |
| | | /// Scan all standard locations. Tags are annotated from TagDatabase by the caller. |
| | | static func scan() -> [AppInfo] { |
| | | static func scan(useCache: Bool = true) -> [AppInfo] { |
| | | if useCache, let cached = cachedScanIfFresh() { |
| | | return cached |
| | | } |
| | | |
| | | let apps = performScan() |
| | | updateScanCache(apps) |
| | | return apps |
| | | } |
| | | |
| | | static func invalidateScanCache() { |
| | | scanCacheLock.lock() |
| | | cachedScanApps = nil |
| | | cachedScanAt = nil |
| | | scanCacheLock.unlock() |
| | | } |
| | | |
| | | private static func cachedScanIfFresh() -> [AppInfo]? { |
| | | scanCacheLock.lock() |
| | | defer { scanCacheLock.unlock() } |
| | | |
| | | guard let cachedScanApps, |
| | | let cachedScanAt, |
| | | Date().timeIntervalSince(cachedScanAt) < scanCacheTTL |
| | | else { return nil } |
| | | return cachedScanApps |
| | | } |
| | | |
| | | private static func updateScanCache(_ apps: [AppInfo]) { |
| | | scanCacheLock.lock() |
| | | cachedScanApps = apps |
| | | cachedScanAt = Date() |
| | | scanCacheLock.unlock() |
| | | } |
| | | |
| | | private static func performScan() -> [AppInfo] { |
| | | var seenResolvedPaths = Set<String>() |
| | | var apps: [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 bundleId = Bundle(url: displayURL)?.bundleIdentifier |
| | | ?? Bundle(url: resolvedURL)?.bundleIdentifier |
| | | let localizedNamesByLanguage = localizedAppNameMap( |
| | | bundle: bundle, |
| | | fallbackName: name |
| | | ) |
| | | let localizedNames = localizedAppNames( |
| | | for: displayURL, |
| | | fallbackName: name, |
| | | localizedNamesByLanguage: localizedNamesByLanguage |
| | | ) |
| | | |
| | | apps.append(AppInfo( |
| | | name: name, |
| | | path: displayURL, |
| | | tags: [], |
| | | bundleIdentifier: bundleId, |
| | | localizedNames: localizedNames, |
| | | localizedNamesByLanguage: localizedNamesByLanguage, |
| | | icon: icon |
| | | )) |
| | | } |
| | | |
| | | private static func isTagLauncherBundle(_ bundleIdentifier: String?) -> Bool { |
| | | bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame |
| | | } |
| | | |
| | | private static func localizedAppNameMap( |
| | | bundle: Bundle?, |
| | | fallbackName: String |
| | | ) -> [String: String] { |
| | | guard let bundle else { return [:] } |
| | | let loctable = infoPlistLoctable(in: bundle) |
| | | var result: [String: String] = [:] |
| | | |
| | | for language in L10n.supported { |
| | | guard let localizedName = localizedAppName( |
| | | for: language.code, |
| | | bundle: bundle, |
| | | loctable: loctable, |
| | | fallbackName: fallbackName |
| | | ) else { continue } |
| | | result[language.code] = localizedName |
| | | } |
| | | |
| | | return result |
| | | } |
| | | |
| | | private static func localizedAppNames( |
| | | for appURL: URL, |
| | | fallbackName: String, |
| | | localizedNamesByLanguage: [String: String] |
| | | ) -> [String] { |
| | | var values = L10n.supported.map { localizedNamesByLanguage[$0.code] } |
| | | values.append(spotlightDisplayName(for: appURL)) |
| | | values.append(FileManager.default.displayName(atPath: appURL.path).replacingOccurrences(of: ".app", with: "")) |
| | | |
| | | return uniqueLocalizedNames(values, excluding: fallbackName) |
| | | } |
| | | |
| | | private static func localizedAppName( |
| | | for languageCode: String, |
| | | bundle: Bundle, |
| | | loctable: [String: [String: Any]], |
| | | fallbackName: String |
| | | ) -> String? { |
| | | for localization in localizationCandidates(for: languageCode) { |
| | | if let table = loctable[localization], |
| | | let value = firstValidLocalizedName( |
| | | [table["CFBundleDisplayName"], table["CFBundleName"]], |
| | | excluding: fallbackName |
| | | ) { |
| | | return value |
| | | } |
| | | |
| | | if let strings = infoPlistStrings(in: bundle, localization: localization), |
| | | let value = firstValidLocalizedName( |
| | | [strings["CFBundleDisplayName"], strings["CFBundleName"]], |
| | | excluding: fallbackName |
| | | ) { |
| | | return value |
| | | } |
| | | } |
| | | |
| | | if languageCode == "en", |
| | | let value = firstValidLocalizedName( |
| | | [ |
| | | bundle.infoDictionary?["CFBundleDisplayName"], |
| | | bundle.infoDictionary?["CFBundleName"] |
| | | ], |
| | | excluding: fallbackName |
| | | ) { |
| | | return value |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | private static func infoPlistLoctable(in bundle: Bundle) -> [String: [String: Any]] { |
| | | guard let url = bundle.url(forResource: "InfoPlist", withExtension: "loctable"), |
| | | let rawTable = NSDictionary(contentsOf: url) as? [String: Any] |
| | | else { return [:] } |
| | | |
| | | var result: [String: [String: Any]] = [:] |
| | | for (key, value) in rawTable { |
| | | if let localizedTable = value as? [String: Any] { |
| | | result[key] = localizedTable |
| | | } |
| | | } |
| | | return result |
| | | } |
| | | |
| | | private static func infoPlistStrings( |
| | | in bundle: Bundle, |
| | | localization: String |
| | | ) -> [String: Any]? { |
| | | guard let url = bundle.url( |
| | | forResource: "InfoPlist", |
| | | withExtension: "strings", |
| | | subdirectory: nil, |
| | | localization: localization |
| | | ) else { return nil } |
| | | return NSDictionary(contentsOf: url) as? [String: Any] |
| | | } |
| | | |
| | | private static func localizationCandidates(for languageCode: String) -> [String] { |
| | | var candidates = [languageCode, languageCode.replacingOccurrences(of: "-", with: "_")] |
| | | switch languageCode { |
| | | case "zh-Hans": |
| | | candidates.append(contentsOf: ["zh_CN", "zh"]) |
| | | case "zh-Hant": |
| | | candidates.append(contentsOf: ["zh_TW", "zh_HK", "zh"]) |
| | | case "pt-BR": |
| | | candidates.append(contentsOf: ["pt_BR", "pt"]) |
| | | case "sr-Cyrl": |
| | | candidates.append(contentsOf: ["sr_Cyrl", "sr"]) |
| | | case "ar-Najdi": |
| | | candidates.append(contentsOf: ["ar_Najdi", "ar"]) |
| | | case "nb": |
| | | candidates.append(contentsOf: ["nb", "no"]) |
| | | case "nn": |
| | | candidates.append(contentsOf: ["nn", "no"]) |
| | | case "no": |
| | | candidates.append(contentsOf: ["no", "nb", "nn"]) |
| | | default: |
| | | if let base = languageCode.split(separator: "-").first.map(String.init) { |
| | | candidates.append(base) |
| | | } |
| | | } |
| | | return AppInfo.uniqueLanguageCodes(candidates) |
| | | } |
| | | |
| | | private static func firstValidLocalizedName( |
| | | _ values: [Any?], |
| | | excluding excludedValue: String |
| | | ) -> String? { |
| | | let normalizedExcluded = normalizedLocalizedName(excludedValue) |
| | | for value in values { |
| | | guard let value = value as? String else { continue } |
| | | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | guard !trimmed.isEmpty, |
| | | normalizedLocalizedName(trimmed) != normalizedExcluded |
| | | else { continue } |
| | | return trimmed |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | private static func spotlightDisplayName(for appURL: URL) -> String? { |
| | | guard let item = MDItemCreate(nil, appURL.path as CFString), |
| | | let value = MDItemCopyAttribute(item, kMDItemDisplayName) as? String |
| | | else { return nil } |
| | | |
| | | let trimmed = value.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>() |
| | | var result: [String] = [] |
| | | |
| | | for value in values { |
| | | guard let value else { continue } |
| | | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | let normalized = normalizedLocalizedName(trimmed) |
| | | guard !trimmed.isEmpty, |
| | | normalized != normalizedExcluded, |
| | | seen.insert(normalized).inserted |
| | | else { continue } |
| | | result.append(trimmed) |
| | | } |
| | | return result |
| | | } |
| | | |
| | | private static func normalizedLocalizedName(_ value: String) -> String { |
| | | value |
| | | .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) |
| | | .lowercased() |
| | | .trimmingCharacters(in: .whitespacesAndNewlines) |
| | | } |
| | | |
| | | private static func isNestedInsideAppBundle(_ url: URL) -> Bool { |
| | |
| | | case manual |
| | | } |
| | | |
| | | enum AppNoteOrigin: String, Codable { |
| | | case catalogDefault |
| | | case appleDefault |
| | | case manual |
| | | } |
| | | |
| | | struct AppNoteMetadata: Codable, Equatable { |
| | | var origin: AppNoteOrigin |
| | | var catalog: SmartDefaultNoteProvenance? = nil |
| | | var noteFingerprint: String |
| | | } |
| | | |
| | | // MARK: Storage types |
| | | |
| | | struct TagDef: Codable, Equatable { |
| | |
| | | var uncommonAppPaths: [String] = [] // special marker; does not affect normal groups |
| | | var uncommonSources: [String: UncommonSource] = [:] // current uncommon source: auto/manual |
| | | var appOpenCounts: [String: Int] = [:] // launches opened from TagLauncher |
| | | var appLastOpenedAt: [String: Date] = [:] // successful launches opened from TagLauncher |
| | | var knownAppPaths: [String] = [] // baseline set to detect newly installed apps |
| | | var appNotes: [String: String] = [:] // path → user note; retained even if marker is removed |
| | | var appNoteMetadata: [String: AppNoteMetadata] = [:] // path → note source and edit protection |
| | | var disabledSystemCategoryIDs: [SmartCategoryID] = [] // system categories the user deleted |
| | | var smartStart: SmartStartState = SmartStartState() |
| | | var categoryScheme: CategorySchemeState = CategorySchemeState() |
| | |
| | | case uncommonAppPaths |
| | | case uncommonSources |
| | | case appOpenCounts |
| | | case appLastOpenedAt |
| | | case knownAppPaths |
| | | case appNotes |
| | | case appNoteMetadata |
| | | case disabledSystemCategoryIDs |
| | | case smartStart |
| | | case categoryScheme |
| | |
| | | uncommonAppPaths = try container.decodeIfPresent([String].self, forKey: .uncommonAppPaths) ?? [] |
| | | uncommonSources = try container.decodeIfPresent([String: UncommonSource].self, forKey: .uncommonSources) ?? [:] |
| | | appOpenCounts = try container.decodeIfPresent([String: Int].self, forKey: .appOpenCounts) ?? [:] |
| | | appLastOpenedAt = try container.decodeIfPresent([String: Date].self, forKey: .appLastOpenedAt) ?? [:] |
| | | knownAppPaths = try container.decodeIfPresent([String].self, forKey: .knownAppPaths) ?? [] |
| | | appNotes = try container.decodeIfPresent([String: String].self, forKey: .appNotes) ?? [:] |
| | | appNoteMetadata = try container.decodeIfPresent( |
| | | [String: AppNoteMetadata].self, |
| | | forKey: .appNoteMetadata |
| | | ) ?? [:] |
| | | disabledSystemCategoryIDs = try container.decodeIfPresent( |
| | | [SmartCategoryID].self, |
| | | forKey: .disabledSystemCategoryIDs |
| | |
| | | // MARK: Paths |
| | | |
| | | private static var storeDir: URL { |
| | | let dir = FileManager.default.homeDirectoryForCurrentUser |
| | | .appendingPathComponent("Library/Application Support/Apptag") |
| | | let dir = AppIdentity.applicationSupportDirectory |
| | | try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| | | return dir |
| | | } |
| | |
| | | return dir |
| | | } |
| | | |
| | | private static var legacyStoreURL: URL? { |
| | | guard ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil else { |
| | | return nil |
| | | } |
| | | |
| | | let home = FileManager.default.homeDirectoryForCurrentUser |
| | | let bundleID = Bundle.main.bundleIdentifier ?? "com.apptag.launcher" |
| | | let marker = "/Library/Containers/\(bundleID)/Data" |
| | | guard let range = home.path.range(of: marker) else { return nil } |
| | | |
| | | let realHomePath = String(home.path[..<range.lowerBound]) |
| | | return URL(fileURLWithPath: realHomePath) |
| | | .appendingPathComponent("Library/Application Support/Apptag/tags.json") |
| | | } |
| | | |
| | | // MARK: Load / Save |
| | | |
| | | static func load() -> Store { |
| | | migrateLegacyStoreIfNeeded() |
| | | guard let data = try? Data(contentsOf: storeURL), |
| | | let store = try? JSONDecoder().decode(Store.self, from: data) |
| | | else { return Store() } |
| | |
| | | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
| | | guard let data = try? encoder.encode(store) else { return } |
| | | try? data.write(to: storeURL, options: .atomic) |
| | | } |
| | | |
| | | static func noteFingerprint(_ value: String) -> String { |
| | | let normalized = String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(maxAppNoteLength)) |
| | | var hash: UInt64 = 0xcbf29ce484222325 |
| | | for byte in normalized.utf8 { |
| | | hash ^= UInt64(byte) |
| | | hash &*= 0x100000001b3 |
| | | } |
| | | return String(format: "%016llx", hash) |
| | | } |
| | | |
| | | static func backup(_ store: Store, reason: String, in directory: URL? = nil) -> URL? { |
| | |
| | | |
| | | private static let categorySchemeBatchDebounceSeconds: TimeInterval = 90 |
| | | private static let categorySchemeRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60 |
| | | private static let categorySchemeBatchQueue = DispatchQueue(label: "com.apptag.category-scheme-batch") |
| | | private static let categorySchemeBatchQueue = DispatchQueue(label: AppIdentity.categorySchemeBatchQueueLabel) |
| | | private static var categorySchemeBatchActive = false |
| | | private static var categorySchemeBatchResetWorkItem: DispatchWorkItem? |
| | | |
| | |
| | | return true |
| | | } |
| | | |
| | | /// App Store sandbox builds read Application Support inside the app container. |
| | | /// Older non-sandbox builds stored the same database directly under ~/Library. |
| | | /// On first sandbox launch, migrate the richer legacy database before seeding defaults. |
| | | static func migrateLegacyStoreIfNeeded() { |
| | | let fm = FileManager.default |
| | | guard let legacyURL = legacyStoreURL, |
| | | fm.fileExists(atPath: legacyURL.path) |
| | | else { return } |
| | | |
| | | guard let legacyData = try? Data(contentsOf: legacyURL), |
| | | let legacyStore = try? JSONDecoder().decode(Store.self, from: legacyData) |
| | | else { return } |
| | | |
| | | if let currentData = try? Data(contentsOf: storeURL), |
| | | let currentStore = try? JSONDecoder().decode(Store.self, from: currentData), |
| | | storeScore(currentStore) >= storeScore(legacyStore) { |
| | | return |
| | | } |
| | | |
| | | try? fm.createDirectory(at: storeDir, withIntermediateDirectories: true) |
| | | try? legacyData.write(to: storeURL, options: .atomic) |
| | | } |
| | | |
| | | private static func storeScore(_ store: Store) -> Int { |
| | | store.appTags.count * 100 + store.tagOrder.count * 10 + store.tags.count |
| | | } |
| | | |
| | | // MARK: Localization |
| | | |
| | | @discardableResult |
| | |
| | | // MARK: Export / Import |
| | | |
| | | static func exportTo(_ url: URL) throws { |
| | | migrateLegacyStoreIfNeeded() |
| | | let store = loadWithEnsuredCategoryScheme() |
| | | let data = try JSONEncoder().encode(store) |
| | | try data.write(to: url, options: .atomic) |
| | |
| | | /// Seed starter system tags on first launch. Smart Start adds any additional |
| | | /// system tags it needs after scanning the user's installed apps. |
| | | static func seedDefaultTags() { |
| | | migrateLegacyStoreIfNeeded() |
| | | guard !FileManager.default.fileExists(atPath: storeURL.path) else { return } |
| | | |
| | | let starterCategoryIDs: [SmartCategoryID] = [ |
| | | .design, |
| | | .development, |
| | | .uiPrototyping, |
| | | .ide, |
| | | .writing, |
| | | .game, |
| | | .entertainment, |
| | | .system, |
| | | .productivity |
| | | .gtd |
| | | ] |
| | | |
| | | var store = Store() |
| | |
| | | let appTags = store.appTags[app.path.path] ?? [] |
| | | return AppInfo( |
| | | name: app.name, path: app.path, tags: appTags, |
| | | bundleIdentifier: app.bundleIdentifier, icon: app.icon, |
| | | bundleIdentifier: app.bundleIdentifier, |
| | | localizedNames: app.localizedNames, |
| | | localizedNamesByLanguage: app.localizedNamesByLanguage, |
| | | icon: app.icon, |
| | | isUncommon: uncommonPaths.contains(app.path.path), |
| | | note: store.appNotes[app.path.path] |
| | | ) |
| | |
| | | } |
| | | |
| | | let newPaths = scannedPaths.subtracting(knownPaths) |
| | | guard !newPaths.isEmpty else { return store } |
| | | let removedPaths = knownPaths.subtracting(scannedPaths) |
| | | if !removedPaths.isEmpty { |
| | | for path in removedPaths { |
| | | store.appOpenCounts.removeValue(forKey: path) |
| | | store.appLastOpenedAt.removeValue(forKey: path) |
| | | } |
| | | store.knownAppPaths = knownPaths.subtracting(removedPaths).sorted() |
| | | } |
| | | |
| | | guard !newPaths.isEmpty else { |
| | | if !removedPaths.isEmpty { |
| | | TagDatabase.save(store) |
| | | } |
| | | return store |
| | | } |
| | | |
| | | var uncommonPaths = Set(store.uncommonAppPaths) |
| | | let appsByPath = Dictionary(uniqueKeysWithValues: apps.map { ($0.path.path, $0) }) |
| | |
| | | let newApps = apps.filter { newPaths.contains($0.path.path) } |
| | | _ = seedDefaultAppleAppNotes(for: newApps, in: &store) |
| | | store.uncommonAppPaths = uncommonPaths.sorted() |
| | | store.knownAppPaths = knownPaths.union(newPaths).sorted() |
| | | store.knownAppPaths = Set(store.knownAppPaths).union(newPaths).sorted() |
| | | TagDatabase.save(store) |
| | | return store |
| | | } |
| | |
| | | } |
| | | |
| | | guard let note = AppleDefaultAppNotes.note(for: app) else { continue } |
| | | store.appNotes[path] = String(note.prefix(TagDatabase.maxAppNoteLength)) |
| | | let limited = String(note.prefix(TagDatabase.maxAppNoteLength)) |
| | | store.appNotes[path] = limited |
| | | store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata( |
| | | origin: .appleDefault, |
| | | noteFingerprint: TagDatabase.noteFingerprint(limited) |
| | | ) |
| | | changed = true |
| | | } |
| | | |
| | |
| | | static func recordLauncherOpen(for path: String) { |
| | | var store = TagDatabase.load() |
| | | store.appOpenCounts[path] = (store.appOpenCounts[path] ?? 0) + 1 |
| | | store.appLastOpenedAt[path] = Date() |
| | | |
| | | if store.uncommonSources[path] == .auto, |
| | | store.appOpenCounts[path, default: 0] >= TagDatabase.autoUncommonOpenThreshold { |
| | |
| | | let limited = String(trimmed.prefix(TagDatabase.maxAppNoteLength)) |
| | | if limited.isEmpty { |
| | | store.appNotes.removeValue(forKey: path) |
| | | store.appNoteMetadata.removeValue(forKey: path) |
| | | } else { |
| | | store.appNotes[path] = limited |
| | | store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata( |
| | | origin: .manual, |
| | | noteFingerprint: TagDatabase.noteFingerprint(limited) |
| | | ) |
| | | var uncommonPaths = Set(store.uncommonAppPaths) |
| | | if uncommonPaths.insert(path).inserted { |
| | | store.uncommonAppPaths = uncommonPaths.sorted() |