From 0ae3a9359e4cdf6ae5b669c4affd91662463f370 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:17:36 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.1 candidate
---
src/Apptag/DataLayer.swift | 218 +++++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 198 insertions(+), 20 deletions(-)
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 2a23bbd..ac1650c 100644
--- a/src/Apptag/DataLayer.swift
+++ b/src/Apptag/DataLayer.swift
@@ -1024,6 +1024,7 @@
var color: Int
var systemCategoryID: SmartCategoryID? = nil
var customColor: TagCustomColor? = nil
+ var isCustomMaintained: Bool? = nil
}
struct Store: Codable {
@@ -1174,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] {
@@ -1528,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],
@@ -1555,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)
@@ -1832,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
@@ -1857,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
@@ -1866,6 +1952,7 @@
previous: previousStore,
reason: "reorder-tags"
)
+ return true
}
/// Persist the visible app order for one stable container.
@@ -1896,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) }
@@ -1907,6 +1998,7 @@
previous: previousStore,
reason: "create-tag"
)
+ return .saved
}
/// Annotate scanned apps with tags from the database.
@@ -1935,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 {
@@ -1944,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) {
@@ -1956,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 {
@@ -1985,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 {
@@ -2018,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 {
@@ -2052,6 +2186,7 @@
previous: previousStore,
reason: "remove-tags"
)
+ return .saved
}
static func reconcileScannedApps(_ apps: [AppInfo]) -> TagDatabase.Store {
@@ -2245,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") {
@@ -2271,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,
@@ -2303,6 +2469,7 @@
previous: previousStore,
reason: "rename-tag"
)
+ return .saved
}
/// Delete a tag completely (from all apps and tag definitions).
@@ -2343,18 +2510,29 @@
}
/// 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.
--
Gitblit v1.9.3