Ariver
2026-05-28 da71eb5bccbdbd22da7cd74b6ddcec031f069482
Apptag/DataLayer.swift
@@ -1,5 +1,6 @@
import Foundation
import AppKit
import CoreServices
// MARK: - Data Models
@@ -9,9 +10,51 @@
    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 {
@@ -51,6 +94,10 @@
// 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"),
@@ -72,7 +119,42 @@
    }
    /// 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] = []
@@ -133,20 +215,211 @@
        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 {
@@ -285,6 +558,18 @@
        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 {
@@ -300,8 +585,10 @@
        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()
@@ -314,8 +601,10 @@
            case uncommonAppPaths
            case uncommonSources
            case appOpenCounts
            case appLastOpenedAt
            case knownAppPaths
            case appNotes
            case appNoteMetadata
            case disabledSystemCategoryIDs
            case smartStart
            case categoryScheme
@@ -332,8 +621,13 @@
            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
@@ -374,8 +668,7 @@
    // 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
    }
@@ -394,25 +687,9 @@
        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() }
@@ -432,6 +709,16 @@
        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? {
@@ -469,7 +756,7 @@
    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?
@@ -611,33 +898,6 @@
        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
@@ -733,7 +993,6 @@
    // 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)
@@ -919,17 +1178,16 @@
    /// 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()
@@ -997,7 +1255,10 @@
            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]
            )
@@ -1142,7 +1403,21 @@
        }
        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) })
@@ -1162,7 +1437,7 @@
        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
    }
@@ -1180,7 +1455,12 @@
            }
            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
        }
@@ -1214,6 +1494,7 @@
    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 {
@@ -1233,8 +1514,13 @@
        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()