From e1226e6ae5a75388fe5d1a0e1ed008f80fee25ed Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 17 May 2026 14:21:25 +0800
Subject: [PATCH] Release Apptag 6.0.5 build 610
---
Apptag/DataLayer.swift | 491 +++++++++++++++++++++++++++++++++++++++++++-----------
1 files changed, 387 insertions(+), 104 deletions(-)
diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index ffcd67f..eacfa1d 100644
--- a/Apptag/DataLayer.swift
+++ b/Apptag/DataLayer.swift
@@ -10,11 +10,13 @@
let tags: [String]
let bundleIdentifier: String?
let icon: NSImage // Pre-loaded during background scan
+ var isUncommon: Bool = false
+ var note: String? = nil
/// True if this is an Apple pre-installed app.
var isAppleApp: Bool {
if let bid = bundleIdentifier, bid.hasPrefix("com.apple.") { return true }
- return path.path.hasPrefix("/System/Applications/")
+ return AppIndexer.isSystemAppPath(path.path)
}
func hash(into hasher: inout Hasher) { hasher.combine(path) }
@@ -43,7 +45,7 @@
default: return NSColor.systemGray
}
}
- static let allIndices: [Int] = [0, 1, 2, 3, 4, 5, 6, 7]
+ static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7]
}
// MARK: - App Scanner
@@ -53,40 +55,46 @@
static let searchPaths: [URL] = [
URL(fileURLWithPath: "/Applications"),
URL(fileURLWithPath: "/System/Applications"),
+ URL(fileURLWithPath: "/System/Cryptexes/App/System/Applications"),
+ URL(fileURLWithPath: "/System/Volumes/Preboot/Cryptexes/App/System/Applications"),
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications")
]
- /// Scan all standard locations. Tags are NOT read from Finder —
- /// they're annotated from TagDatabase by the caller.
+ private static let systemAppPathPrefixes = [
+ "/System/Applications/",
+ "/System/Cryptexes/App/System/Applications/",
+ "/System/Volumes/Preboot/Cryptexes/App/System/Applications/"
+ ]
+
+ static func isSystemAppPath(_ path: String) -> Bool {
+ systemAppPathPrefixes.contains { path.hasPrefix($0) }
+ }
+
+ /// Scan all standard locations. Tags are annotated from TagDatabase by the caller.
static func scan() -> [AppInfo] {
- var seen = Set<URL>()
+ var seenResolvedPaths = Set<String>()
var apps: [AppInfo] = []
for baseURL in searchPaths {
+ scanDirectChildren(
+ in: baseURL,
+ apps: &apps,
+ seenResolvedPaths: &seenResolvedPaths
+ )
+
guard let enumerator = FileManager.default.enumerator(
at: baseURL,
- includingPropertiesForKeys: [.isDirectoryKey],
+ includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else { continue }
for case let url as URL in enumerator {
- guard url.pathExtension == "app" else { continue }
- guard !seen.contains(url) else { continue }
- seen.insert(url)
-
- let name = url.deletingPathExtension().lastPathComponent
- let icon = NSWorkspace.shared.icon(forFile: url.path)
- icon.size = NSSize(width: 96, height: 96)
- var bundleId: String? = nil
- if let bundle = Bundle(url: url) {
- bundleId = bundle.bundleIdentifier
- }
-
- apps.append(AppInfo(
- name: name, path: url, tags: [],
- bundleIdentifier: bundleId, icon: icon
- ))
+ appendAppIfNeeded(
+ at: url,
+ apps: &apps,
+ seenResolvedPaths: &seenResolvedPaths
+ )
}
}
@@ -95,11 +103,56 @@
}
}
+ private static func scanDirectChildren(
+ in baseURL: URL,
+ apps: inout [AppInfo],
+ seenResolvedPaths: inout Set<String>
+ ) {
+ guard let names = try? FileManager.default.contentsOfDirectory(atPath: baseURL.path) else { return }
+
+ for name in names where !name.hasPrefix(".") {
+ appendAppIfNeeded(
+ at: baseURL.appendingPathComponent(name),
+ apps: &apps,
+ seenResolvedPaths: &seenResolvedPaths
+ )
+ }
+ }
+
+ private static func appendAppIfNeeded(
+ at url: URL,
+ apps: inout [AppInfo],
+ seenResolvedPaths: inout Set<String>
+ ) {
+ guard url.pathExtension.lowercased() == "app" else { return }
+
+ let displayURL = url.standardizedFileURL
+ let resolvedURL = displayURL.resolvingSymlinksInPath().standardizedFileURL
+ guard seenResolvedPaths.insert(resolvedURL.path).inserted 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
+
+ apps.append(AppInfo(
+ name: name,
+ path: displayURL,
+ tags: [],
+ bundleIdentifier: bundleId,
+ icon: icon
+ ))
+ }
+
/// Group apps by their tags (from TagDatabase).
+ /// Groups are sorted by tagOrder (user-defined), falling back to alpha.
static func group(
apps: [AppInfo],
nameOverrides: [String: String] = [:],
- defaultGroupName: String = "Other"
+ defaultGroupName: String = "Other",
+ tagOrder: [String] = []
) -> [TagGroup] {
let macCategory = "Mac自带"
var dict: [String: [AppInfo]] = [:]
@@ -116,21 +169,37 @@
}
}
+ // Build sort index from tagOrder: lower index = appears first
+ var orderIndex: [String: Int] = [:]
+ for (i, name) in tagOrder.enumerated() {
+ orderIndex[name] = i
+ }
+
return dict
.sorted { lhs, rhs in
if lhs.key == macCategory { return false }
if rhs.key == macCategory { return true }
if lhs.key == defaultGroupName { return false }
if rhs.key == defaultGroupName { return true }
+ // Use custom order if both are in tagOrder
+ let li = orderIndex[lhs.key] ?? Int.max
+ let ri = orderIndex[rhs.key] ?? Int.max
+ if li != ri { return li < ri }
return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending
}
.map { TagGroup(name: $0.key, apps: $0.value) }
}
}
-// MARK: - Local Tag Database (JSON-backed)
-
enum TagDatabase {
+ static let uncommonTagKey = "__system.uncommon"
+ static let maxAppNoteLength = 80
+ static let autoUncommonOpenThreshold = 100
+
+ enum UncommonSource: String, Codable {
+ case auto
+ case manual
+ }
// MARK: Storage types
@@ -142,7 +211,43 @@
var version: Int = 1
var tags: [String: TagDef] = [:]
var appTags: [String: [String]] = [:] // path → tag names
- var migrated: Bool = false
+ var tagOrder: [String] = [] // display order; empty → alpha sort
+ 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 knownAppPaths: [String] = [] // baseline set to detect newly installed apps
+ var appNotes: [String: String] = [:] // path → user note; retained even if marker is removed
+
+ enum CodingKeys: String, CodingKey {
+ case version
+ case tags
+ case appTags
+ case tagOrder
+ case uncommonAppPaths
+ case uncommonSources
+ case appOpenCounts
+ case knownAppPaths
+ case appNotes
+ }
+
+ init() {}
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
+ tags = try container.decodeIfPresent([String: TagDef].self, forKey: .tags) ?? [:]
+ appTags = try container.decodeIfPresent([String: [String]].self, forKey: .appTags) ?? [:]
+ tagOrder = try container.decodeIfPresent([String].self, forKey: .tagOrder) ?? []
+ 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) ?? [:]
+ knownAppPaths = try container.decodeIfPresent([String].self, forKey: .knownAppPaths) ?? []
+ appNotes = try container.decodeIfPresent([String: String].self, forKey: .appNotes) ?? [:]
+
+ for path in uncommonAppPaths where uncommonSources[path] == nil {
+ uncommonSources[path] = .manual
+ }
+ }
}
// MARK: Paths
@@ -156,9 +261,25 @@
static var storeURL: URL { storeDir.appendingPathComponent("tags.json") }
+ 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() }
@@ -166,91 +287,46 @@
}
static func save(_ store: Store) {
- guard let data = try? JSONEncoder().encode(store) else { return }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ guard let data = try? encoder.encode(store) else { return }
try? data.write(to: storeURL, options: .atomic)
}
- // MARK: Migration (first launch: import Finder tags)
+ /// 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 }
- /// Run once on first launch. Reads Finder tags from all scanned apps
- /// and seeds the local database. Also auto-assigns "Mac自带" to Apple apps.
- static func migrateFromFinderIfNeeded(apps: [AppInfo]) -> Store {
- var store = load()
- guard !store.migrated else { return store }
+ guard let legacyData = try? Data(contentsOf: legacyURL),
+ let legacyStore = try? JSONDecoder().decode(Store.self, from: legacyData)
+ else { return }
- // Read Finder tags from each app
- for app in apps {
- let finderTags = readFinderTags(from: app.path)
- guard !finderTags.isEmpty else { continue }
- store.appTags[app.path.path] = finderTags.map { $0.name }
- for (name, color) in finderTags {
- if store.tags[name] == nil {
- store.tags[name] = TagDef(color: color)
- }
- }
+ if let currentData = try? Data(contentsOf: storeURL),
+ let currentStore = try? JSONDecoder().decode(Store.self, from: currentData),
+ storeScore(currentStore) >= storeScore(legacyStore) {
+ return
}
- // Auto-assign "Mac自带" tag to Apple apps
- for app in apps where app.isAppleApp {
- var current = store.appTags[app.path.path] ?? []
- if !current.contains("Mac自带") {
- current.append("Mac自带")
- store.appTags[app.path.path] = current
- }
- }
- if store.tags["Mac自带"] == nil {
- store.tags["Mac自带"] = TagDef(color: 1) // gray
- }
-
- store.migrated = true
- save(store)
- return store
+ try? fm.createDirectory(at: storeDir, withIntermediateDirectories: true)
+ try? legacyData.write(to: storeURL, options: .atomic)
}
- /// Read Finder tags (name, color) from a single .app bundle.
- /// Only used during one-time migration.
- private static func readFinderTags(from url: URL) -> [(name: String, color: Int)] {
- // Read tag names via NSURL resource values
- guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]),
- let tagNames = values.tagNames, !tagNames.isEmpty
- else { return [] }
-
- // Read tag colors from xattr
- let xattrName = "com.apple.metadata:_kMDItemUserTags"
- let path = url.path
- let size = getxattr(path, xattrName, nil, 0, 0, 0)
- guard size > 0 else {
- return tagNames.map { ($0, 0) }
- }
-
- var buffer = [UInt8](repeating: 0, count: size)
- guard getxattr(path, xattrName, &buffer, size, 0, 0) == size else {
- return tagNames.map { ($0, 0) }
- }
-
- let data = Data(buffer)
- guard let plist = try? PropertyListSerialization.propertyList(
- from: data, options: [], format: nil
- ) as? [String] else {
- return tagNames.map { ($0, 0) }
- }
-
- // Build name→color map from xattr entries
- var colorMap: [String: Int] = [:]
- for entry in plist {
- let parts = entry.components(separatedBy: "\n")
- guard !parts[0].isEmpty else { continue }
- let c = (parts.count >= 2) ? (Int(parts[1]) ?? 0) : 0
- colorMap[parts[0]] = (0...7).contains(c) ? c : 0
- }
-
- return tagNames.map { ($0, colorMap[$0] ?? 0) }
+ private static func storeScore(_ store: Store) -> Int {
+ store.appTags.count * 100 + store.tagOrder.count * 10 + store.tags.count
}
// MARK: Export / Import
static func exportTo(_ url: URL) throws {
- try FileManager.default.copyItem(at: storeURL, to: url)
+ migrateLegacyStoreIfNeeded()
+ let store = load()
+ let data = try JSONEncoder().encode(store)
+ try data.write(to: url, options: .atomic)
}
static func importFrom(_ url: URL) throws -> Store {
@@ -258,6 +334,28 @@
let store = try JSONDecoder().decode(Store.self, from: data)
save(store)
return store
+ }
+
+ /// Seed default tags on first launch. Only runs if store doesn't exist yet.
+ /// Tag names are loaded from the current language's localization.
+ static func seedDefaultTags() {
+ migrateLegacyStoreIfNeeded()
+ guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
+
+ let keys = [
+ "tag.design", "tag.development", "tag.writing",
+ "tag.gaming", "tag.entertainment", "tag.system",
+ "tag.productivity"
+ ]
+ let colors: [Int] = [1, 2, 3, 4, 5, 6, 7]
+
+ var store = Store()
+ for (i, key) in keys.enumerated() {
+ let name = tr(key)
+ store.tags[name] = TagDef(color: colors[i])
+ store.tagOrder.append(name)
+ }
+ save(store)
}
}
@@ -273,14 +371,44 @@
return store.tags.mapValues { $0.color }
}
+ /// Tag names in display order. Falls back to alpha sort if no custom order.
+ static func orderedTagNames() -> [String] {
+ let store = TagDatabase.load()
+ let ordered = store.tagOrder.filter { store.tags[$0] != nil }
+ let remaining = store.tags.keys.filter { !ordered.contains($0) }.sorted()
+ return ordered + remaining
+ }
+
+ /// Persist a new tag display order.
+ static func reorderTags(_ names: [String]) {
+ var store = TagDatabase.load()
+ store.tagOrder = names
+ TagDatabase.save(store)
+ }
+
+ /// Create a new tag definition (no app assignments yet).
+ static func createTag(_ name: String, color: Int) {
+ var store = TagDatabase.load()
+ guard store.tags[name] == nil else { return }
+ store.tags[name] = TagDatabase.TagDef(color: color)
+ if !store.tagOrder.contains(name) { store.tagOrder.insert(name, at: 0) }
+ TagDatabase.save(store)
+ }
+
/// Annotate scanned apps with tags from the database.
static func annotate(apps: [AppInfo]) -> [AppInfo] {
- let store = TagDatabase.load()
+ annotate(apps: apps, store: TagDatabase.load())
+ }
+
+ static func annotate(apps: [AppInfo], store: TagDatabase.Store) -> [AppInfo] {
+ let uncommonPaths = Set(store.uncommonAppPaths)
return apps.map { app in
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, icon: app.icon,
+ isUncommon: uncommonPaths.contains(app.path.path),
+ note: store.appNotes[app.path.path]
)
}
}
@@ -291,7 +419,11 @@
static func assignTag(_ tag: String, color: Int, to paths: [String]) {
var store = TagDatabase.load()
// Ensure tag definition exists
- if store.tags[tag] == nil { store.tags[tag] = TagDatabase.TagDef(color: color) }
+ if store.tags[tag] == nil {
+ store.tags[tag] = TagDatabase.TagDef(color: color)
+ // New tag: append to display order
+ if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
+ }
for path in paths {
var current = store.appTags[path] ?? []
if !current.contains(tag) {
@@ -302,14 +434,164 @@
TagDatabase.save(store)
}
- /// Remove a tag from specific apps.
- static func removeTag(_ tag: String, from paths: [String]) {
+ /// Replace the full editable tag set for multiple apps.
+ static func setTags(_ tags: [String], to paths: [String]) {
var store = TagDatabase.load()
+ let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
+ let validTags = tags.filter { store.tags[$0] != nil }
+ var uncommonPaths = Set(store.uncommonAppPaths)
for path in paths {
- store.appTags[path]?.removeAll { $0 == tag }
- if store.appTags[path]?.isEmpty == true {
+ if validTags.isEmpty {
store.appTags.removeValue(forKey: path)
+ } else {
+ store.appTags[path] = validTags
}
+ if selectedUncommon {
+ uncommonPaths.insert(path)
+ store.uncommonSources[path] = .manual
+ } else {
+ uncommonPaths.remove(path)
+ store.uncommonSources.removeValue(forKey: path)
+ }
+ }
+ store.uncommonAppPaths = uncommonPaths.sorted()
+ TagDatabase.save(store)
+ }
+
+ /// Append tags to multiple apps without disturbing their existing tag sets.
+ static func appendTags(_ tags: [String], to paths: [String]) {
+ guard !tags.isEmpty, !paths.isEmpty else { return }
+
+ var store = TagDatabase.load()
+ let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
+ let validTags = tags.filter { store.tags[$0] != nil }
+ var uncommonPaths = Set(store.uncommonAppPaths)
+
+ for path in paths {
+ var current = store.appTags[path] ?? []
+ for tag in validTags where !current.contains(tag) {
+ current.append(tag)
+ }
+ if !current.isEmpty {
+ store.appTags[path] = current
+ }
+
+ if selectedUncommon {
+ uncommonPaths.insert(path)
+ store.uncommonSources[path] = .manual
+ }
+ }
+
+ store.uncommonAppPaths = uncommonPaths.sorted()
+ TagDatabase.save(store)
+ }
+
+ /// Remove tags from multiple apps while preserving every unrelated tag.
+ static func removeTags(_ tags: [String], from paths: [String]) {
+ guard !tags.isEmpty, !paths.isEmpty else { return }
+
+ var store = TagDatabase.load()
+ let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
+ let validTags = Set(tags.filter { store.tags[$0] != nil })
+ var uncommonPaths = Set(store.uncommonAppPaths)
+
+ for path in paths {
+ var current = store.appTags[path] ?? []
+ current.removeAll { validTags.contains($0) }
+
+ if current.isEmpty {
+ store.appTags.removeValue(forKey: path)
+ } else {
+ store.appTags[path] = current
+ }
+
+ if selectedUncommon {
+ uncommonPaths.remove(path)
+ store.uncommonSources.removeValue(forKey: path)
+ }
+ }
+
+ store.uncommonAppPaths = uncommonPaths.sorted()
+ TagDatabase.save(store)
+ }
+
+ static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
+ var store = TagDatabase.load()
+ let scannedPaths = Set(apps.map { $0.path.path })
+ let knownPaths = Set(store.knownAppPaths)
+
+ if store.knownAppPaths.isEmpty {
+ store.knownAppPaths = scannedPaths.sorted()
+ if apps.isEmpty == false {
+ TagDatabase.save(store)
+ }
+ return store
+ }
+
+ let newPaths = scannedPaths.subtracting(knownPaths)
+ guard !newPaths.isEmpty else { return store }
+
+ var uncommonPaths = Set(store.uncommonAppPaths)
+ for path in newPaths {
+ uncommonPaths.insert(path)
+ store.uncommonSources[path] = .auto
+ if store.appOpenCounts[path] == nil {
+ store.appOpenCounts[path] = 0
+ }
+ }
+
+ store.uncommonAppPaths = uncommonPaths.sorted()
+ store.knownAppPaths = knownPaths.union(newPaths).sorted()
+ TagDatabase.save(store)
+ return store
+ }
+
+ static func recordLauncherOpen(for path: String) {
+ var store = TagDatabase.load()
+ store.appOpenCounts[path] = (store.appOpenCounts[path] ?? 0) + 1
+
+ if store.uncommonSources[path] == .auto,
+ store.appOpenCounts[path, default: 0] >= TagDatabase.autoUncommonOpenThreshold {
+ var uncommonPaths = Set(store.uncommonAppPaths)
+ uncommonPaths.remove(path)
+ store.uncommonAppPaths = uncommonPaths.sorted()
+ store.uncommonSources.removeValue(forKey: path)
+ }
+
+ TagDatabase.save(store)
+ }
+
+ static func setAppNote(_ note: String, for path: String) {
+ var store = TagDatabase.load()
+ let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ let limited = String(trimmed.prefix(TagDatabase.maxAppNoteLength))
+ if limited.isEmpty {
+ store.appNotes.removeValue(forKey: path)
+ } else {
+ store.appNotes[path] = limited
+ }
+ TagDatabase.save(store)
+ }
+
+ static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
+ var store = TagDatabase.load()
+ if store.tags[targetTag] == nil {
+ store.tags[targetTag] = TagDatabase.TagDef(color: color)
+ if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
+ }
+
+ var current = store.appTags[path] ?? []
+ if !copy, !sourceTag.isEmpty {
+ current.removeAll { $0 == sourceTag }
+ }
+ if !current.contains(targetTag) {
+ current.append(targetTag)
+ }
+
+ if current.isEmpty {
+ store.appTags.removeValue(forKey: path)
+ } else {
+ store.appTags[path] = current
}
TagDatabase.save(store)
}
@@ -335,6 +617,7 @@
static func deleteTagCompletely(_ tag: String) {
var store = TagDatabase.load()
store.tags.removeValue(forKey: tag)
+ store.tagOrder.removeAll { $0 == tag }
for (path, var tags) in store.appTags {
tags.removeAll { $0 == tag }
if tags.isEmpty {
--
Gitblit v1.9.3