From beebaf7b68b3749f4a1bc4d916e23906b8c16d7f Mon Sep 17 00:00:00 2001
From: Ariver <ar@MacBook-Air.local>
Date: Wed, 13 May 2026 23:31:23 +0800
Subject: [PATCH] Release TagLauncher 5.6.0
---
Apptag/DataLayer.swift | 181 +++++++++++++++++++++++++-------------------
1 files changed, 102 insertions(+), 79 deletions(-)
diff --git a/Apptag/DataLayer.swift b/Apptag/DataLayer.swift
index 7dc2cbd..2eb3d99 100644
--- a/Apptag/DataLayer.swift
+++ b/Apptag/DataLayer.swift
@@ -43,7 +43,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
@@ -57,8 +57,7 @@
.appendingPathComponent("Applications")
]
- /// Scan all standard locations. Tags are NOT read from Finder —
- /// they're annotated from TagDatabase by the caller.
+ /// Scan all standard locations. Tags are annotated from TagDatabase by the caller.
static func scan() -> [AppInfo] {
var seen = Set<URL>()
var apps: [AppInfo] = []
@@ -153,7 +152,6 @@
var tags: [String: TagDef] = [:]
var appTags: [String: [String]] = [:] // path → tag names
var tagOrder: [String] = [] // display order; empty → alpha sort
- var migrated: Bool = false
}
// MARK: Paths
@@ -167,9 +165,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() }
@@ -181,87 +195,40 @@
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 {
@@ -269,6 +236,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)
}
}
@@ -299,6 +288,15 @@
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()
@@ -320,7 +318,7 @@
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.append(tag) }
+ if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
}
for path in paths {
var current = store.appTags[path] ?? []
@@ -332,18 +330,43 @@
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 validTags = tags.filter { store.tags[$0] != nil }
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
}
}
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)
+ }
+
/// Rename a tag on all apps that have it.
static func renameTag(from oldName: String, to newName: String) {
var store = TagDatabase.load()
--
Gitblit v1.9.3