From d77d35981ccd3f64c34727b8d21ac599012007bb Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 02 Jul 2026 02:42:34 +0800
Subject: [PATCH] Update App Store release records

---
 src/Apptag/DataLayer.swift |  299 +++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 files changed, 279 insertions(+), 20 deletions(-)

diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 1357d95..ac1650c 100644
--- a/src/Apptag/DataLayer.swift
+++ b/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)
@@ -1771,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
 
@@ -1796,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
@@ -1805,6 +1952,7 @@
             previous: previousStore,
             reason: "reorder-tags"
         )
+        return true
     }
 
     /// Persist the visible app order for one stable container.
@@ -1835,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) }
@@ -1846,6 +1998,7 @@
             previous: previousStore,
             reason: "create-tag"
         )
+        return .saved
     }
 
     /// Annotate scanned apps with tags from the database.
@@ -1874,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 {
@@ -1883,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) {
@@ -1895,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 {
@@ -1924,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 {
@@ -1957,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 {
@@ -1991,6 +2186,7 @@
             previous: previousStore,
             reason: "remove-tags"
         )
+        return .saved
     }
 
     static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
@@ -2184,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") {
@@ -2210,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,
@@ -2242,6 +2469,7 @@
             previous: previousStore,
             reason: "rename-tag"
         )
+        return .saved
     }
 
     /// Delete a tag completely (from all apps and tag definitions).
@@ -2282,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
     }
 }

--
Gitblit v1.9.3