From da71eb5bccbdbd22da7cd74b6ddcec031f069482 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 15:59:53 +0800
Subject: [PATCH] Stabilize empty-drop tag removal confirmation

---
 Apptag/DataLayer.swift |  404 +++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 345 insertions(+), 59 deletions(-)

diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index 6b0f69a..9f9b9f0 100644
--- a/Apptag/DataLayer.swift
+++ b/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()

--
Gitblit v1.9.3