Ariver
2026-05-20 7c97841d07d4a596508f663c887d1f67d105faf6
Apptag/DataLayer.swift
@@ -51,6 +51,10 @@
// MARK: - App Scanner
enum AppIndexer {
    private static let scanCacheLock = NSLock()
    private static var cachedScanApps: [AppInfo]? = nil
    private static var cachedScanAt: Date? = nil
    private static let scanCacheTTL: TimeInterval = 1800
    static let searchPaths: [URL] = [
        URL(fileURLWithPath: "/Applications"),
@@ -72,7 +76,42 @@
    }
    /// Scan all standard locations. Tags are annotated from TagDatabase by the caller.
    static func scan() -> [AppInfo] {
    static func scan(useCache: Bool = true) -> [AppInfo] {
        if useCache, let cached = cachedScanIfFresh() {
            return cached
        }
        let apps = performScan()
        updateScanCache(apps)
        return apps
    }
    static func invalidateScanCache() {
        scanCacheLock.lock()
        cachedScanApps = nil
        cachedScanAt = nil
        scanCacheLock.unlock()
    }
    private static func cachedScanIfFresh() -> [AppInfo]? {
        scanCacheLock.lock()
        defer { scanCacheLock.unlock() }
        guard let cachedScanApps,
              let cachedScanAt,
              Date().timeIntervalSince(cachedScanAt) < scanCacheTTL
        else { return nil }
        return cachedScanApps
    }
    private static func updateScanCache(_ apps: [AppInfo]) {
        scanCacheLock.lock()
        cachedScanApps = apps
        cachedScanAt = Date()
        scanCacheLock.unlock()
    }
    private static func performScan() -> [AppInfo] {
        var seenResolvedPaths = Set<String>()
        var apps: [AppInfo] = []
@@ -220,12 +259,18 @@
        var seenAppIDsByGroup: [String: Set<URL>] = [:]
        for app in apps {
            if app.isAppleApp {
                appendGroupedApp(app, to: macCategory, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
            }
            if app.tags.isEmpty {
                let bucket = app.isAppleApp ? macCategory : defaultGroupName
                appendGroupedApp(app, to: bucket, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
                if !app.isAppleApp {
                    appendGroupedApp(app, to: defaultGroupName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
                }
            } else {
                for tag in uniqueOrdered(app.tags) {
                    let displayName = nameOverrides[tag] ?? tag
                    guard displayName != macCategory else { continue }
                    appendGroupedApp(app, to: displayName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
                }
            }
@@ -294,6 +339,7 @@
        var uncommonAppPaths: [String] = []  // special marker; does not affect normal groups
        var uncommonSources: [String: UncommonSource] = [:]  // current uncommon source: auto/manual
        var appOpenCounts: [String: Int] = [:]  // launches opened from TagLauncher
        var appLastOpenedAt: [String: Date] = [:]  // successful launches opened from TagLauncher
        var knownAppPaths: [String] = []  // baseline set to detect newly installed apps
        var appNotes: [String: String] = [:]  // path → user note; retained even if marker is removed
        var disabledSystemCategoryIDs: [SmartCategoryID] = []  // system categories the user deleted
@@ -308,6 +354,7 @@
            case uncommonAppPaths
            case uncommonSources
            case appOpenCounts
            case appLastOpenedAt
            case knownAppPaths
            case appNotes
            case disabledSystemCategoryIDs
@@ -326,6 +373,7 @@
            uncommonAppPaths = try container.decodeIfPresent([String].self, forKey: .uncommonAppPaths) ?? []
            uncommonSources = try container.decodeIfPresent([String: UncommonSource].self, forKey: .uncommonSources) ?? [:]
            appOpenCounts = try container.decodeIfPresent([String: Int].self, forKey: .appOpenCounts) ?? [:]
            appLastOpenedAt = try container.decodeIfPresent([String: Date].self, forKey: .appLastOpenedAt) ?? [:]
            knownAppPaths = try container.decodeIfPresent([String].self, forKey: .knownAppPaths) ?? []
            appNotes = try container.decodeIfPresent([String: String].self, forKey: .appNotes) ?? [:]
            disabledSystemCategoryIDs = try container.decodeIfPresent(
@@ -1136,7 +1184,21 @@
        }
        let newPaths = scannedPaths.subtracting(knownPaths)
        guard !newPaths.isEmpty else { return store }
        let removedPaths = knownPaths.subtracting(scannedPaths)
        if !removedPaths.isEmpty {
            for path in removedPaths {
                store.appOpenCounts.removeValue(forKey: path)
                store.appLastOpenedAt.removeValue(forKey: path)
            }
            store.knownAppPaths = knownPaths.subtracting(removedPaths).sorted()
        }
        guard !newPaths.isEmpty else {
            if !removedPaths.isEmpty {
                TagDatabase.save(store)
            }
            return store
        }
        var uncommonPaths = Set(store.uncommonAppPaths)
        let appsByPath = Dictionary(uniqueKeysWithValues: apps.map { ($0.path.path, $0) })
@@ -1156,7 +1218,7 @@
        let newApps = apps.filter { newPaths.contains($0.path.path) }
        _ = seedDefaultAppleAppNotes(for: newApps, in: &store)
        store.uncommonAppPaths = uncommonPaths.sorted()
        store.knownAppPaths = knownPaths.union(newPaths).sorted()
        store.knownAppPaths = Set(store.knownAppPaths).union(newPaths).sorted()
        TagDatabase.save(store)
        return store
    }
@@ -1208,6 +1270,7 @@
    static func recordLauncherOpen(for path: String) {
        var store = TagDatabase.load()
        store.appOpenCounts[path] = (store.appOpenCounts[path] ?? 0) + 1
        store.appLastOpenedAt[path] = Date()
        if store.uncommonSources[path] == .auto,
           store.appOpenCounts[path, default: 0] >= TagDatabase.autoUncommonOpenThreshold {
@@ -1253,7 +1316,7 @@
        }
        var current = store.appTags[path] ?? []
        if !copy, !sourceTag.isEmpty {
        if !copy, !sourceTag.isEmpty, sourceTag != "Mac自带", sourceTag != tr("group.appleBuiltIn") {
            current.removeAll { $0 == sourceTag }
        }
        if !current.contains(targetTag) {