Ariver
2026-07-02 0f7ee6e29f70017dd4fdfe2dd3a6106f7204f04f
src/Apptag/DataLayer.swift
@@ -237,6 +237,52 @@
// MARK: - Tag Color Mapping
struct TagCustomColor: Codable, Equatable {
    var red: Double
    var green: Double
    var blue: Double
    var alpha: Double
    init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
        self.red = Self.clamp(red)
        self.green = Self.clamp(green)
        self.blue = Self.clamp(blue)
        self.alpha = Self.clamp(alpha)
    }
    init(nsColor: NSColor) {
        let rgbColor = nsColor.usingColorSpace(.sRGB) ?? nsColor
        self.init(
            red: Double(rgbColor.redComponent),
            green: Double(rgbColor.greenComponent),
            blue: Double(rgbColor.blueComponent),
            alpha: Double(rgbColor.alphaComponent)
        )
    }
    var nsColor: NSColor {
        NSColor(
            srgbRed: CGFloat(red),
            green: CGFloat(green),
            blue: CGFloat(blue),
            alpha: CGFloat(alpha)
        )
    }
    var signature: String {
        [
            String(format: "%.4f", red),
            String(format: "%.4f", green),
            String(format: "%.4f", blue),
            String(format: "%.4f", alpha)
        ].joined(separator: ":")
    }
    private static func clamp(_ value: Double) -> Double {
        min(1.0, max(0.0, value))
    }
}
enum TagColor {
    static func nsColor(for index: Int) -> NSColor {
        switch index {
@@ -251,6 +297,20 @@
        default: return NSColor.systemGray
        }
    }
    static func nsColor(for index: Int, customColor: TagCustomColor?) -> NSColor {
        customColor?.nsColor ?? nsColor(for: index)
    }
    static func prefersDarkText(for color: NSColor) -> Bool {
        let rgbColor = color.usingColorSpace(.sRGB) ?? color
        let red = Double(rgbColor.redComponent)
        let green = Double(rgbColor.greenComponent)
        let blue = Double(rgbColor.blueComponent)
        let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
        return luminance > 0.62
    }
    static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7]
}
@@ -963,6 +1023,8 @@
    struct TagDef: Codable, Equatable {
        var color: Int
        var systemCategoryID: SmartCategoryID? = nil
        var customColor: TagCustomColor? = nil
        var isCustomMaintained: Bool? = nil
    }
    struct Store: Codable {
@@ -1113,6 +1175,40 @@
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        guard let data = try? encoder.encode(normalizedStore) else { return }
        try? data.write(to: storeURL, options: .atomic)
    }
    static func customMaintainedContainerCount(in store: Store? = nil) -> Int {
        customMaintainedContainerNames(in: store).count
    }
    static func customMaintainedContainerNames(in store: Store? = nil) -> [String] {
        let effectiveStore = store ?? load()
        return effectiveStore.tags
            .filter { isCustomMaintainedTag(name: $0.key, definition: $0.value) }
            .map(\.key)
            .sorted()
    }
    static func isCustomMaintainedTag(name: String, definition: TagDef) -> Bool {
        guard let systemCategoryID = definition.systemCategoryID else {
            return true
        }
        if definition.isCustomMaintained == true {
            return true
        }
        if definition.customColor != nil {
            return true
        }
        return definition.color != systemCategoryID.defaultColorIndex
    }
    static func markCustomMaintainedIfNeeded(_ tagName: String, in store: inout Store) {
        guard var definition = store.tags[tagName],
              definition.systemCategoryID != nil,
              !isCustomMaintainedTag(name: tagName, definition: definition)
        else { return }
        definition.isCustomMaintained = true
        store.tags[tagName] = definition
    }
    static func normalizedAppOrderPaths(_ paths: [String]) -> [String] {
@@ -1467,6 +1563,7 @@
        for (tagName, tagDef) in store.tags {
            guard let categoryID = tagDef.systemCategoryID else { continue }
            guard !isCustomMaintainedTag(name: tagName, definition: tagDef) else { continue }
            let targetName = categoryID.localizedDisplayName
            guard targetName != tagName else { continue }
            if let existing = store.tags[targetName],
@@ -1494,7 +1591,9 @@
    ) -> (name: String, created: Bool)? {
        if let existingName = systemTagName(for: categoryID, in: store) {
            let targetName = categoryID.localizedDisplayName
            if existingName != targetName {
            let existingDefinition = store.tags[existingName]
            if existingName != targetName,
               existingDefinition.map({ !isCustomMaintainedTag(name: existingName, definition: $0) }) ?? true {
                if let existing = store.tags[targetName],
                   existing.systemCategoryID != categoryID {
                    return (existingName, false)
@@ -1755,12 +1854,11 @@
        guard !FileManager.default.fileExists(atPath: storeURL.path) else { return }
        let starterCategoryIDs: [SmartCategoryID] = [
            .uiPrototyping,
            .ide,
            .writing,
            .game,
            .design,
            .development,
            .office,
            .entertainment,
            .system,
            .systemEnhancement,
            .gtd
        ]
@@ -1772,9 +1870,55 @@
    }
}
enum TagEditorMutationResult {
    case saved
    case noChange
    case blockedCustomContainerQuota(ProCustomContainerQuotaStatus)
    var didSave: Bool {
        if case .saved = self {
            return true
        }
        return false
    }
}
// MARK: - Tag Editor (CRUD against local TagDatabase)
enum TagEditor {
    private static func customContainerCandidates(
        _ tagNames: Set<String>,
        in store: TagDatabase.Store
    ) -> Set<String> {
        Set(tagNames.filter { tagName in
            guard let definition = store.tags[tagName] else {
                return true
            }
            return !TagDatabase.isCustomMaintainedTag(name: tagName, definition: definition)
        })
    }
    private static func customContainerMaintenanceResult(
        for tagNames: Set<String>,
        in store: TagDatabase.Store
    ) -> TagEditorMutationResult? {
        let addedCount = customContainerCandidates(tagNames, in: store).count
        switch ProEntitlementPolicy.customContainerMaintenanceDecision(
            addingCustomContainerCount: addedCount,
            in: store
        ) {
        case .allow:
            return nil
        case .blocked(let status):
            return .blockedCustomContainerQuota(status)
        }
    }
    private static func markCustomMaintained(_ tagNames: Set<String>, in store: inout TagDatabase.Store) {
        for tagName in tagNames {
            TagDatabase.markCustomMaintainedIfNeeded(tagName, in: &store)
        }
    }
    // MARK: Read
@@ -1797,7 +1941,9 @@
    }
    /// Persist a new tag display order.
    static func reorderTags(_ names: [String]) {
    @discardableResult
    static func reorderTags(_ names: [String]) -> Bool {
        guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return false }
        var store = TagDatabase.load()
        let previousStore = store
        store.tagOrder = names
@@ -1806,6 +1952,7 @@
            previous: previousStore,
            reason: "reorder-tags"
        )
        return true
    }
    /// Persist the visible app order for one stable container.
@@ -1836,9 +1983,13 @@
    }
    /// Create a new tag definition (no app assignments yet).
    static func createTag(_ name: String, color: Int) {
    @discardableResult
    static func createTag(_ name: String, color: Int) -> TagEditorMutationResult {
        var store = TagDatabase.load()
        guard store.tags[name] == nil else { return }
        guard store.tags[name] == nil else { return .noChange }
        if let blocked = customContainerMaintenanceResult(for: [name], in: store) {
            return blocked
        }
        let previousStore = store
        store.tags[name] = TagDatabase.TagDef(color: color, systemCategoryID: nil)
        if !store.tagOrder.contains(name) { store.tagOrder.insert(name, at: 0) }
@@ -1847,6 +1998,7 @@
            previous: previousStore,
            reason: "create-tag"
        )
        return .saved
    }
    /// Annotate scanned apps with tags from the database.
@@ -1875,8 +2027,12 @@
    // MARK: Write
    /// Assign a tag to multiple apps. Preserves existing tags.
    static func assignTag(_ tag: String, color: Int, to paths: [String]) {
    @discardableResult
    static func assignTag(_ tag: String, color: Int, to paths: [String]) -> TagEditorMutationResult {
        var store = TagDatabase.load()
        if let blocked = customContainerMaintenanceResult(for: [tag], in: store) {
            return blocked
        }
        let previousStore = store
        // Ensure tag definition exists
        if store.tags[tag] == nil {
@@ -1884,6 +2040,7 @@
            // New tag: append to display order
            if !store.tagOrder.contains(tag) { store.tagOrder.insert(tag, at: 0) }
        }
        markCustomMaintained([tag], in: &store)
        for path in paths {
            var current = store.appTags[path] ?? []
            if !current.contains(tag) {
@@ -1896,14 +2053,27 @@
            previous: previousStore,
            reason: "assign-tag"
        )
        return .saved
    }
    /// Replace the full editable tag set for multiple apps.
    static func setTags(_ tags: [String], to paths: [String]) {
    @discardableResult
    static func setTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult {
        var store = TagDatabase.load()
        let previousStore = store
        let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
        let validTags = tags.filter { store.tags[$0] != nil }
        let nextValidTags = Set(validTags)
        var touchedTags = Set<String>()
        for path in paths {
            let currentTags = Set(store.appTags[path] ?? [])
            touchedTags.formUnion(currentTags.symmetricDifference(nextValidTags))
        }
        touchedTags = touchedTags.filter { store.tags[$0] != nil }
        if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
            return blocked
        }
        let previousStore = store
        markCustomMaintained(touchedTags, in: &store)
        var uncommonPaths = Set(store.uncommonAppPaths)
        for path in paths {
            if validTags.isEmpty {
@@ -1925,16 +2095,29 @@
            previous: previousStore,
            reason: "set-tags"
        )
        return .saved
    }
    /// 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 }
    @discardableResult
    static func appendTags(_ tags: [String], to paths: [String]) -> TagEditorMutationResult {
        guard !tags.isEmpty, !paths.isEmpty else { return .noChange }
        var store = TagDatabase.load()
        let previousStore = store
        let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
        let validTags = tags.filter { store.tags[$0] != nil }
        var touchedTags = Set<String>()
        for path in paths {
            let current = Set(store.appTags[path] ?? [])
            for tag in validTags where !current.contains(tag) {
                touchedTags.insert(tag)
            }
        }
        if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
            return blocked
        }
        let previousStore = store
        markCustomMaintained(touchedTags, in: &store)
        var uncommonPaths = Set(store.uncommonAppPaths)
        for path in paths {
@@ -1958,16 +2141,27 @@
            previous: previousStore,
            reason: "append-tags"
        )
        return .saved
    }
    /// 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 }
    @discardableResult
    static func removeTags(_ tags: [String], from paths: [String]) -> TagEditorMutationResult {
        guard !tags.isEmpty, !paths.isEmpty else { return .noChange }
        var store = TagDatabase.load()
        let previousStore = store
        let selectedUncommon = tags.contains(TagDatabase.uncommonTagKey)
        let validTags = Set(tags.filter { store.tags[$0] != nil })
        var touchedTags = Set<String>()
        for path in paths {
            let current = Set(store.appTags[path] ?? [])
            touchedTags.formUnion(validTags.intersection(current))
        }
        if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
            return blocked
        }
        let previousStore = store
        markCustomMaintained(touchedTags, in: &store)
        var uncommonPaths = Set(store.uncommonAppPaths)
        for path in paths {
@@ -1992,6 +2186,7 @@
            previous: previousStore,
            reason: "remove-tags"
        )
        return .saved
    }
    static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
@@ -2185,13 +2380,30 @@
        return decision
    }
    static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) {
    @discardableResult
    static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) -> TagEditorMutationResult {
        var store = TagDatabase.load()
        var touchedTags = Set<String>()
        let currentTags = Set(store.appTags[path] ?? [])
        if store.tags[targetTag] == nil || !currentTags.contains(targetTag) {
            touchedTags.insert(targetTag)
        }
        if !copy,
           !sourceTag.isEmpty,
           sourceTag != "Mac自带",
           sourceTag != tr("group.appleBuiltIn"),
           currentTags.contains(sourceTag) {
            touchedTags.insert(sourceTag)
        }
        if let blocked = customContainerMaintenanceResult(for: touchedTags, in: store) {
            return blocked
        }
        let previousStore = store
        if store.tags[targetTag] == nil {
            store.tags[targetTag] = TagDatabase.TagDef(color: color, systemCategoryID: nil)
            if !store.tagOrder.contains(targetTag) { store.tagOrder.insert(targetTag, at: 0) }
        }
        markCustomMaintained(touchedTags, in: &store)
        var current = store.appTags[path] ?? []
        if !copy, !sourceTag.isEmpty, sourceTag != "Mac自带", sourceTag != tr("group.appleBuiltIn") {
@@ -2211,17 +2423,31 @@
            previous: previousStore,
            reason: "move-app"
        )
        return .saved
    }
    /// Rename a tag on all apps that have it.
    static func renameTag(from oldName: String, to newName: String) {
    @discardableResult
    static func renameTag(from oldName: String, to newName: String) -> TagEditorMutationResult {
        var store = TagDatabase.load()
        guard oldName != newName, store.tags[newName] == nil else { return }
        guard oldName != newName, store.tags[newName] == nil else { return .noChange }
        guard let oldDefinition = store.tags[oldName] else { return .noChange }
        let needsCustomContainerSlot = !TagDatabase.isCustomMaintainedTag(
            name: oldName,
            definition: oldDefinition
        )
        if needsCustomContainerSlot,
           let blocked = customContainerMaintenanceResult(for: [oldName], in: store) {
            return blocked
        }
        let previousStore = store
        // Update tag definition
        if let def = store.tags.removeValue(forKey: oldName) {
        if var def = store.tags.removeValue(forKey: oldName) {
            let oldContainerID = AppContainerID.forTag(oldName, definition: def)
            let newContainerID = AppContainerID.forTag(newName, definition: def)
            if def.systemCategoryID != nil {
                def.isCustomMaintained = true
            }
            store.tags[newName] = def
            TagDatabase.migrateContainerAppOrderKey(
                in: &store,
@@ -2243,6 +2469,7 @@
            previous: previousStore,
            reason: "rename-tag"
        )
        return .saved
    }
    /// Delete a tag completely (from all apps and tag definitions).
@@ -2283,16 +2510,47 @@
    }
    /// Set the color for a tag.
    static func setColor(_ color: Int, for tag: String) {
    @discardableResult
    static func setColor(_ color: Int, for tag: String) -> TagEditorMutationResult {
        var store = TagDatabase.load()
        let previousStore = store
        var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: color, systemCategoryID: nil)
        let existingDefinition = store.tags[tag]
        var tagDef = existingDefinition ?? TagDatabase.TagDef(color: color, systemCategoryID: nil)
        let wasCustomMaintained = existingDefinition.map {
            TagDatabase.isCustomMaintainedTag(name: tag, definition: $0)
        } ?? false
        tagDef.color = color
        tagDef.customColor = nil
        let willBeCustomMaintained = TagDatabase.isCustomMaintainedTag(name: tag, definition: tagDef)
        if !wasCustomMaintained, willBeCustomMaintained,
           let blocked = customContainerMaintenanceResult(for: [tag], in: store) {
            return blocked
        }
        let previousStore = store
        store.tags[tag] = tagDef
        TagDatabase.saveUserCategorySchemeMutation(
            store,
            previous: previousStore,
            reason: "set-tag-color"
        )
        return .saved
    }
    /// Set a Pro custom color for a tag without changing its fallback base color.
    @discardableResult
    static func setCustomColor(_ customColor: TagCustomColor, for tag: String) -> Bool {
        guard ProEntitlementPolicy.isUnlocked(.customTagColors) else {
            return false
        }
        var store = TagDatabase.load()
        let previousStore = store
        var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: 0, systemCategoryID: nil)
        tagDef.customColor = customColor
        store.tags[tag] = tagDef
        TagDatabase.saveUserCategorySchemeMutation(
            store,
            previous: previousStore,
            reason: "set-tag-custom-color"
        )
        return true
    }
}