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