| | |
| | | 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 |
| | |
| | | .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] = [] |
| | |
| | | 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 |
| | |
| | | |
| | | 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() } |
| | |
| | | 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 { |
| | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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() |
| | |
| | | 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] ?? [] |
| | |
| | | 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() |