diff --git a/PluginUpdater/Pluginventory/App/AppState.swift b/PluginUpdater/Pluginventory/App/AppState.swift index aec6a6d..3a25311 100644 --- a/PluginUpdater/Pluginventory/App/AppState.swift +++ b/PluginUpdater/Pluginventory/App/AppState.swift @@ -31,6 +31,7 @@ final class AppState { private let appUpdateChecker = AppUpdateChecker() private let manifestCacheManager = ManifestCacheManager() private let bundleSizeService = BundleSizeService() + private let offloadManager = OffloadManager() private var prefetchTask: Task? private var sizeComputationTask: Task? @@ -530,6 +531,66 @@ final class AppState { } } + // MARK: - Offloading + + /// Offloads the given plugins: moves each bundle into the offload folder, + /// records it for restore, and removes it from the active plugin list. + /// Returns how many succeeded and failed. + @discardableResult + func offloadPlugins(_ pluginsToOffload: [Plugin]) async -> (offloaded: Int, failed: Int) { + let context = modelContainer.mainContext + var offloaded = 0 + var failed = 0 + for plugin in pluginsToOffload { + do { + let record = try await offloadManager.offload(bundleAt: plugin.pathURL) + let item = OffloadedPlugin( + name: plugin.name, + vendorName: plugin.vendorName, + bundleIdentifier: plugin.bundleIdentifier, + formatRaw: plugin.format.rawValue, + originalPath: record.originalPath, + offloadedPath: record.offloadedPath, + sizeBytes: record.sizeBytes, + offloadedAt: record.offloadedAt + ) + context.insert(item) + context.delete(plugin) + offloaded += 1 + } catch { + failed += 1 + AppLogger.shared.error("Offload failed for \(plugin.name): \(error.localizedDescription)", category: "offload") + } + } + try? context.save() + if failed > 0 { + errorMessage = "\(failed) plugin(s) could not be offloaded" + } + let countDescriptor = FetchDescriptor(predicate: #Predicate { !$0.isRemoved }) + totalPluginCount = (try? context.fetchCount(countDescriptor)) ?? totalPluginCount + return (offloaded, failed) + } + + /// Restores previously offloaded plugins to their original locations, then rescans. + func restoreOffloaded(_ items: [OffloadedPlugin]) async { + let context = modelContainer.mainContext + var restored = 0 + for item in items { + do { + try await offloadManager.restore(item.offloadRecord) + context.delete(item) + restored += 1 + } catch { + errorMessage = "Restore failed: \(error.localizedDescription)" + AppLogger.shared.error("Restore failed for \(item.name): \(error.localizedDescription)", category: "offload") + } + } + try? context.save() + if restored > 0 { + await performScan() + } + } + // MARK: - Project Scanning func performProjectScan() async { diff --git a/PluginUpdater/Pluginventory/Models/OffloadedPlugin.swift b/PluginUpdater/Pluginventory/Models/OffloadedPlugin.swift new file mode 100644 index 0000000..788ee44 --- /dev/null +++ b/PluginUpdater/Pluginventory/Models/OffloadedPlugin.swift @@ -0,0 +1,54 @@ +import Foundation +import SwiftData + +/// A plugin bundle that has been moved out of its install location into the +/// offload folder to reclaim disk space. Retains everything needed to move it +/// back to exactly where it came from. +@Model +final class OffloadedPlugin { + var name: String + var vendorName: String + var bundleIdentifier: String + var formatRaw: String + var originalPath: String + var offloadedPath: String + var sizeBytes: Int64 + var offloadedAt: Date + + init( + name: String, + vendorName: String, + bundleIdentifier: String, + formatRaw: String, + originalPath: String, + offloadedPath: String, + sizeBytes: Int64, + offloadedAt: Date + ) { + self.name = name + self.vendorName = vendorName + self.bundleIdentifier = bundleIdentifier + self.formatRaw = formatRaw + self.originalPath = originalPath + self.offloadedPath = offloadedPath + self.sizeBytes = sizeBytes + self.offloadedAt = offloadedAt + } + + var format: PluginFormat? { PluginFormat(rawValue: formatRaw) } + + var sizeDisplay: String { + ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + } + + /// The record needed by `OffloadManager` to restore this bundle. + var offloadRecord: OffloadRecord { + OffloadRecord( + originalPath: originalPath, + offloadedPath: offloadedPath, + bundleName: URL(fileURLWithPath: originalPath).lastPathComponent, + sizeBytes: sizeBytes, + offloadedAt: offloadedAt + ) + } +} diff --git a/PluginUpdater/Pluginventory/Models/Plugin.swift b/PluginUpdater/Pluginventory/Models/Plugin.swift index b1f1792..1d3930a 100644 --- a/PluginUpdater/Pluginventory/Models/Plugin.swift +++ b/PluginUpdater/Pluginventory/Models/Plugin.swift @@ -20,6 +20,8 @@ final class Plugin { /// Lets `BundleSizeService` skip re-walking unchanged bundles across scans. var sizeCacheMtime: Date? var fileCreationDate: Date? + /// Functional category (raw value), assigned by PluginCategoryClassifier. + var categoryRaw: String = PluginCategory.uncategorized.rawValue @Relationship(deleteRule: .cascade, inverse: \PluginVersion.plugin) var versionHistory: [PluginVersion] @@ -56,6 +58,12 @@ final class Plugin { URL(fileURLWithPath: path) } + /// Parsed functional category. + var category: PluginCategory { + get { PluginCategory(rawValue: categoryRaw) ?? .uncategorized } + set { categoryRaw = newValue.rawValue } + } + /// Parsed CPU architectures from the raw string. var architectures: [CPUArchitecture] { get { diff --git a/PluginUpdater/Pluginventory/Models/PluginCategory.swift b/PluginUpdater/Pluginventory/Models/PluginCategory.swift new file mode 100644 index 0000000..487338e --- /dev/null +++ b/PluginUpdater/Pluginventory/Models/PluginCategory.swift @@ -0,0 +1,57 @@ +import Foundation + +/// A functional category an audio plugin can be classified into +/// (e.g. by ``PluginCategoryClassifier``). +enum PluginCategory: String, Codable, CaseIterable, Sendable, Identifiable { + case synth + case sampler + case drums + case eq + case dynamics + case compressor + case saturation + case distortion + case reverb + case delay + case modulation + case filter + case pitch + case mastering + case metering + case analyzer + case channelStrip + case utility + case guitarAmp + case noiseReduction + case creative + case uncategorized + + var id: String { rawValue } + + var displayName: String { + switch self { + case .synth: "Synth" + case .sampler: "Sampler" + case .drums: "Drums" + case .eq: "EQ" + case .dynamics: "Dynamics" + case .compressor: "Compressor" + case .saturation: "Saturation" + case .distortion: "Distortion" + case .reverb: "Reverb" + case .delay: "Delay" + case .modulation: "Modulation" + case .filter: "Filter" + case .pitch: "Pitch" + case .mastering: "Mastering" + case .metering: "Metering" + case .analyzer: "Analyzer" + case .channelStrip: "Channel Strip" + case .utility: "Utility" + case .guitarAmp: "Guitar/Amp" + case .noiseReduction: "Noise Reduction" + case .creative: "Creative" + case .uncategorized: "Uncategorized" + } + } +} diff --git a/PluginUpdater/Pluginventory/Resources/plugin_categories.json b/PluginUpdater/Pluginventory/Resources/plugin_categories.json new file mode 100644 index 0000000..32afa06 --- /dev/null +++ b/PluginUpdater/Pluginventory/Resources/plugin_categories.json @@ -0,0 +1,477 @@ +[ + { + "match": "com.fabfilter.Pro-Q", + "field": "bundleIDPrefix", + "category": "eq" + }, + { + "match": "com.fabfilter.Pro-C", + "field": "bundleIDPrefix", + "category": "compressor" + }, + { + "match": "com.fabfilter.Pro-DS", + "field": "bundleIDPrefix", + "category": "dynamics" + }, + { + "match": "com.fabfilter.Pro-G", + "field": "bundleIDPrefix", + "category": "dynamics" + }, + { + "match": "com.fabfilter.Pro-MB", + "field": "bundleIDPrefix", + "category": "compressor" + }, + { + "match": "com.fabfilter.Pro-L", + "field": "bundleIDPrefix", + "category": "dynamics" + }, + { + "match": "com.fabfilter.Pro-R", + "field": "bundleIDPrefix", + "category": "reverb" + }, + { + "match": "com.fabfilter.Saturn", + "field": "bundleIDPrefix", + "category": "saturation" + }, + { + "match": "com.fabfilter.Timeless", + "field": "bundleIDPrefix", + "category": "delay" + }, + { + "match": "com.fabfilter.Volcano", + "field": "bundleIDPrefix", + "category": "filter" + }, + { + "match": "com.fabfilter.Simplon", + "field": "bundleIDPrefix", + "category": "filter" + }, + { + "match": "com.fabfilter.Twin", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.izotope.Ozone", + "field": "bundleIDPrefix", + "category": "mastering" + }, + { + "match": "com.izotope.Insight", + "field": "bundleIDPrefix", + "category": "metering" + }, + { + "match": "com.izotope.Neutron", + "field": "bundleIDPrefix", + "category": "channelStrip" + }, + { + "match": "com.izotope.Nectar", + "field": "bundleIDPrefix", + "category": "channelStrip" + }, + { + "match": "com.izotope.RX", + "field": "bundleIDPrefix", + "category": "noiseReduction" + }, + { + "match": "com.izotope.Trash", + "field": "bundleIDPrefix", + "category": "distortion" + }, + { + "match": "com.izotope.VocalSynth", + "field": "bundleIDPrefix", + "category": "creative" + }, + { + "match": "com.ValhallaDSP.", + "field": "bundleIDPrefix", + "category": "reverb" + }, + { + "match": "com.ValhallaDSP.ValhallaDelay", + "field": "bundleIDPrefix", + "category": "delay" + }, + { + "match": "com.ValhallaDSP.ValhallaFreqEcho", + "field": "bundleIDPrefix", + "category": "delay" + }, + { + "match": "com.ValhallaDSP.ValhallaSpaceModulator", + "field": "bundleIDPrefix", + "category": "modulation" + }, + { + "match": "com.ValhallaDSP.ValhallaSupermassive", + "field": "bundleIDPrefix", + "category": "reverb" + }, + { + "match": "com.soundtoys.", + "field": "bundleIDPrefix", + "category": "creative" + }, + { + "match": "com.soundtoys.EchoBoy", + "field": "bundleIDPrefix", + "category": "delay" + }, + { + "match": "com.soundtoys.Decapitator", + "field": "bundleIDPrefix", + "category": "saturation" + }, + { + "match": "com.soundtoys.PhaseMistress", + "field": "bundleIDPrefix", + "category": "modulation" + }, + { + "match": "com.soundtoys.MicroShift", + "field": "bundleIDPrefix", + "category": "pitch" + }, + { + "match": "com.native-instruments.Massive", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.native-instruments.Absynth", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.native-instruments.FM8", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.native-instruments.Kontakt", + "field": "bundleIDPrefix", + "category": "sampler" + }, + { + "match": "com.native-instruments.Battery", + "field": "bundleIDPrefix", + "category": "drums" + }, + { + "match": "com.native-instruments.GuitarRig", + "field": "bundleIDPrefix", + "category": "guitarAmp" + }, + { + "match": "com.xferrecords.serum", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.xferrecords.LFOTool", + "field": "bundleIDPrefix", + "category": "modulation" + }, + { + "match": "com.xferrecords.OTT", + "field": "bundleIDPrefix", + "category": "compressor" + }, + { + "match": "com.u-he.", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.u-he.Presswerk", + "field": "bundleIDPrefix", + "category": "compressor" + }, + { + "match": "com.u-he.Satin", + "field": "bundleIDPrefix", + "category": "saturation" + }, + { + "match": "com.u-he.ColourCopy", + "field": "bundleIDPrefix", + "category": "delay" + }, + { + "match": "com.spectrasonics.Omnisphere", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.spectrasonics.Keyscape", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.spectrasonics.Stylus", + "field": "bundleIDPrefix", + "category": "drums" + }, + { + "match": "com.oeksound.soothe", + "field": "bundleIDPrefix", + "category": "dynamics" + }, + { + "match": "com.oeksound.spiff", + "field": "bundleIDPrefix", + "category": "dynamics" + }, + { + "match": "com.antarestech.AutoTune", + "field": "bundleIDPrefix", + "category": "pitch" + }, + { + "match": "com.celemony.Melodyne", + "field": "bundleIDPrefix", + "category": "pitch" + }, + { + "match": "com.toguaudioline.", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.toguaudioline.TAL-Reverb", + "field": "bundleIDPrefix", + "category": "reverb" + }, + { + "match": "com.toontrack.EZdrummer", + "field": "bundleIDPrefix", + "category": "drums" + }, + { + "match": "com.toontrack.Superior", + "field": "bundleIDPrefix", + "category": "drums" + }, + { + "match": "com.xlnaudio.AddictiveDrums", + "field": "bundleIDPrefix", + "category": "drums" + }, + { + "match": "com.eventide.", + "field": "bundleIDPrefix", + "category": "reverb" + }, + { + "match": "com.eventide.Blackhole", + "field": "bundleIDPrefix", + "category": "reverb" + }, + { + "match": "com.arturia.", + "field": "bundleIDPrefix", + "category": "synth" + }, + { + "match": "com.cableguys.", + "field": "bundleIDPrefix", + "category": "modulation" + }, + { + "match": "com.kilohearts.", + "field": "bundleIDPrefix", + "category": "creative" + }, + { + "match": "com.output.", + "field": "bundleIDPrefix", + "category": "creative" + }, + { + "match": "com.softube.", + "field": "bundleIDPrefix", + "category": "saturation" + }, + { + "match": "com.slatedigital.", + "field": "bundleIDPrefix", + "category": "channelStrip" + }, + { + "match": "com.sonnox.", + "field": "bundleIDPrefix", + "category": "mastering" + }, + { + "match": "Arturia", + "field": "vendor", + "category": "synth" + }, + { + "match": "u-he", + "field": "vendor", + "category": "synth" + }, + { + "match": "Xfer Records", + "field": "vendor", + "category": "synth" + }, + { + "match": "Cableguys", + "field": "vendor", + "category": "modulation" + }, + { + "match": "Softube", + "field": "vendor", + "category": "saturation" + }, + { + "match": "Sonnox", + "field": "vendor", + "category": "mastering" + }, + { + "match": "Waves", + "field": "vendor", + "category": "dynamics" + }, + { + "match": "Slate Digital", + "field": "vendor", + "category": "channelStrip" + }, + { + "match": "Plugin Alliance", + "field": "vendor", + "category": "channelStrip" + }, + { + "match": "Spitfire Audio", + "field": "vendor", + "category": "sampler" + }, + { + "match": "Output", + "field": "vendor", + "category": "creative" + }, + { + "match": "Kilohearts", + "field": "vendor", + "category": "creative" + }, + { + "match": "Valhalla DSP", + "field": "vendor", + "category": "reverb" + }, + { + "match": "Newfangled Audio", + "field": "vendor", + "category": "mastering" + }, + { + "match": "MeldaProduction", + "field": "vendor", + "category": "utility" + }, + { + "match": "Tokyo Dawn Labs", + "field": "vendor", + "category": "dynamics" + }, + { + "match": "Klanghelm", + "field": "vendor", + "category": "compressor" + }, + { + "match": "Spectrasonics", + "field": "vendor", + "category": "synth" + }, + { + "match": "Toontrack", + "field": "vendor", + "category": "drums" + }, + { + "match": "reFX", + "field": "vendor", + "category": "synth" + }, + { + "match": "Roland", + "field": "vendor", + "category": "synth" + }, + { + "match": "KORG", + "field": "vendor", + "category": "synth" + }, + { + "match": "Antares", + "field": "vendor", + "category": "pitch" + }, + { + "match": "Celemony", + "field": "vendor", + "category": "pitch" + }, + { + "match": "Oeksound", + "field": "vendor", + "category": "dynamics" + }, + { + "match": "de-esser", + "field": "nameKeyword", + "category": "dynamics" + }, + { + "match": "transient", + "field": "nameKeyword", + "category": "dynamics" + }, + { + "match": "maximizer", + "field": "nameKeyword", + "category": "mastering" + }, + { + "match": "exciter", + "field": "nameKeyword", + "category": "saturation" + }, + { + "match": "bitcrush", + "field": "nameKeyword", + "category": "creative" + }, + { + "match": "vocoder", + "field": "nameKeyword", + "category": "creative" + }, + { + "match": "convolution", + "field": "nameKeyword", + "category": "reverb" + } +] diff --git a/PluginUpdater/Pluginventory/Services/Analysis/ArchitectureCompatibility.swift b/PluginUpdater/Pluginventory/Services/Analysis/ArchitectureCompatibility.swift new file mode 100644 index 0000000..1e3c089 --- /dev/null +++ b/PluginUpdater/Pluginventory/Services/Analysis/ArchitectureCompatibility.swift @@ -0,0 +1,46 @@ +/// A namespace of pure functions that classify a binary's CPU architecture +/// slices and determine how it will fare once Apple retires Rosetta 2 in macOS 28. +enum ArchitectureCompatibility { + + static func isUniversal(_ archs: [CPUArchitecture]) -> Bool { + archs.contains(.arm64) && archs.contains(.x86_64) + } + + static func isAppleSiliconNative(_ archs: [CPUArchitecture]) -> Bool { + archs.contains(.arm64) + } + + static func isIntelOnly(_ archs: [CPUArchitecture]) -> Bool { + archs.contains(.x86_64) && !archs.contains(.arm64) + } + + static func requiresRosetta(_ archs: [CPUArchitecture]) -> Bool { + guard !archs.contains(.arm64) else { return false } + return archs.contains(.x86_64) + || archs.contains(.i386) + || archs.contains(.ppc) + } + + static func breaksInMacOS28(_ archs: [CPUArchitecture]) -> Bool { + requiresRosetta(archs) + } + + static func compatibilityReason(_ archs: [CPUArchitecture]) -> String? { + guard breaksInMacOS28(archs) else { return nil } + if archs.contains(.i386) || archs.contains(.ppc) { + return "Legacy 32-bit/PowerPC — won't run on modern macOS" + } + return "Intel-only — needs Rosetta, which Apple removes in macOS 28" + } +} + +/// A roll-up of how many plugins will stop working once macOS 28 removes Rosetta 2. +struct RosettaSunsetSummary: Sendable { + let brokenCount: Int + let total: Int + + static func summarize(_ archLists: [[CPUArchitecture]]) -> RosettaSunsetSummary { + let broken = archLists.filter { ArchitectureCompatibility.breaksInMacOS28($0) }.count + return RosettaSunsetSummary(brokenCount: broken, total: archLists.count) + } +} diff --git a/PluginUpdater/Pluginventory/Services/Analysis/DuplicateDetector.swift b/PluginUpdater/Pluginventory/Services/Analysis/DuplicateDetector.swift new file mode 100644 index 0000000..3948914 --- /dev/null +++ b/PluginUpdater/Pluginventory/Services/Analysis/DuplicateDetector.swift @@ -0,0 +1,137 @@ +/// A single plugin instance considered by ``DuplicateDetector``. +struct DuplicatePluginInput: Sendable, Hashable { + let name: String + let vendorName: String + let bundleIdentifier: String + let format: PluginFormat + let path: String +} + +/// A collection of plugin instances that were detected as duplicates of one another. +struct DuplicateGroup: Sendable, Identifiable { + let id: String + let displayName: String + let members: [DuplicatePluginInput] +} + +/// The result of analyzing a set of plugins for duplicates. +struct DuplicateReport: Sendable { + let formatGroups: [DuplicateGroup] + let locationGroups: [DuplicateGroup] + let duplicatePaths: Set + var formatDuplicateCount: Int { formatGroups.count } + var locationDuplicateCount: Int { locationGroups.count } +} + +/// Detects cross-format and cross-location duplicate plugins. +/// +/// - Cross-format duplicates: the same plugin (matched case-insensitively on +/// vendor + name) available in two or more distinct formats. +/// - Location duplicates: the same plugin + format (matched on bundle +/// identifier + format) installed at two or more distinct paths. +struct DuplicateDetector: Sendable { + + func analyze(_ inputs: [DuplicatePluginInput]) -> DuplicateReport { + let formatGroups = makeFormatGroups(inputs) + let locationGroups = makeLocationGroups(inputs) + + var duplicatePaths: Set = [] + for group in formatGroups { + for member in group.members { + duplicatePaths.insert(member.path) + } + } + for group in locationGroups { + for member in group.members { + duplicatePaths.insert(member.path) + } + } + + return DuplicateReport( + formatGroups: formatGroups, + locationGroups: locationGroups, + duplicatePaths: duplicatePaths + ) + } + + // MARK: - Cross-format + + private func makeFormatGroups(_ inputs: [DuplicatePluginInput]) -> [DuplicateGroup] { + var buckets: [String: [DuplicatePluginInput]] = [:] + for input in inputs { + let key = "\(input.vendorName.lowercased())|\(input.name.lowercased())" + buckets[key, default: []].append(input) + } + + var groups: [DuplicateGroup] = [] + for (key, members) in buckets { + let distinctFormats = Set(members.map { $0.format }) + guard distinctFormats.count >= 2 else { continue } + + let sortedMembers = sorted(members) + guard let representative = sortedMembers.first else { continue } + let displayName = "\(representative.vendorName) – \(representative.name)" + groups.append( + DuplicateGroup(id: key, displayName: displayName, members: sortedMembers) + ) + } + + return sorted(groups) + } + + // MARK: - Location + + private func makeLocationGroups(_ inputs: [DuplicatePluginInput]) -> [DuplicateGroup] { + var buckets: [String: [DuplicatePluginInput]] = [:] + for input in inputs { + let key = "\(input.bundleIdentifier)|\(input.format.rawValue)" + buckets[key, default: []].append(input) + } + + var groups: [DuplicateGroup] = [] + for (key, members) in buckets { + let distinctPaths = Set(members.map { $0.path }) + guard distinctPaths.count >= 2 else { continue } + + let sortedMembers = sorted(members) + guard let representative = sortedMembers.first else { continue } + groups.append( + DuplicateGroup( + id: key, + displayName: representative.bundleIdentifier, + members: sortedMembers + ) + ) + } + + return sorted(groups) + } + + // MARK: - Deterministic ordering + + private func sorted(_ groups: [DuplicateGroup]) -> [DuplicateGroup] { + groups.sorted { lhs, rhs in + let left = lhs.displayName.lowercased() + let right = rhs.displayName.lowercased() + if left != right { + return left < right + } + return lhs.id < rhs.id + } + } + + private func sorted(_ members: [DuplicatePluginInput]) -> [DuplicatePluginInput] { + members.sorted { lhs, rhs in + if lhs.format.rawValue != rhs.format.rawValue { + return lhs.format.rawValue < rhs.format.rawValue + } + if lhs.path != rhs.path { + return lhs.path < rhs.path + } + if lhs.bundleIdentifier != rhs.bundleIdentifier { + return lhs.bundleIdentifier < rhs.bundleIdentifier + } + return lhs.name < rhs.name + } + } +} diff --git a/PluginUpdater/Pluginventory/Services/Analysis/InsightsData.swift b/PluginUpdater/Pluginventory/Services/Analysis/InsightsData.swift new file mode 100644 index 0000000..5fe6b2a --- /dev/null +++ b/PluginUpdater/Pluginventory/Services/Analysis/InsightsData.swift @@ -0,0 +1,96 @@ +import Foundation + +/// A single plugin's data as consumed by the Insights dashboard's analysis layer. +struct InsightPlugin: Sendable { + let name: String + let vendorName: String + let format: PluginFormat + let category: PluginCategory + let fileSize: Int64 +} + +/// An immutable, precomputed snapshot of aggregate statistics for a collection +/// of plugins, backing the "Insights" dashboard. +struct InsightsData: Sendable { + + struct FormatCount: Sendable, Identifiable { + let format: PluginFormat + let count: Int + var id: String { format.rawValue } + } + + struct CategoryCount: Sendable, Identifiable { + let category: PluginCategory + let count: Int + var id: String { category.rawValue } + } + + struct ManufacturerCount: Sendable, Identifiable { + let name: String + let count: Int + var id: String { name } + } + + let pluginCount: Int + let manufacturerCount: Int + let totalSizeBytes: Int64 + let formatCounts: [FormatCount] + let categoryCounts: [CategoryCount] + let manufacturersRanked: [ManufacturerCount] + + static func build(from plugins: [InsightPlugin]) -> InsightsData { + let pluginCount = plugins.count + let totalSizeBytes = plugins.reduce(Int64(0)) { $0 + $1.fileSize } + + var vendorTally: [String: Int] = [:] + var formatTally: [PluginFormat: Int] = [:] + var categoryTally: [PluginCategory: Int] = [:] + + for plugin in plugins { + vendorTally[plugin.vendorName, default: 0] += 1 + formatTally[plugin.format, default: 0] += 1 + categoryTally[plugin.category, default: 0] += 1 + } + + let formatCounts = formatTally + .map { FormatCount(format: $0.key, count: $0.value) } + .sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.format.displayName < rhs.format.displayName + } + + let categoryCounts = categoryTally + .map { CategoryCount(category: $0.key, count: $0.value) } + .sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.category.displayName < rhs.category.displayName + } + + let manufacturersRanked = vendorTally + .map { ManufacturerCount(name: $0.key, count: $0.value) } + .sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.name < rhs.name + } + + return InsightsData( + pluginCount: pluginCount, + manufacturerCount: vendorTally.count, + totalSizeBytes: totalSizeBytes, + formatCounts: formatCounts, + categoryCounts: categoryCounts, + manufacturersRanked: manufacturersRanked + ) + } + + func topManufacturers(_ limit: Int) -> [ManufacturerCount] { + guard limit > 0 else { return [] } + return Array(manufacturersRanked.prefix(limit)) + } +} diff --git a/PluginUpdater/Pluginventory/Services/Catalog/PluginCategoryClassifier.swift b/PluginUpdater/Pluginventory/Services/Catalog/PluginCategoryClassifier.swift new file mode 100644 index 0000000..3ee914a --- /dev/null +++ b/PluginUpdater/Pluginventory/Services/Catalog/PluginCategoryClassifier.swift @@ -0,0 +1,258 @@ +import Foundation + +// MARK: - CategoryMapping + +/// A single rule used by ``PluginCategoryClassifier`` to map a plugin onto a +/// ``PluginCategory``. `match` is compared case-insensitively against the plugin +/// attribute named by `field`. +struct CategoryMapping: Codable, Sendable { + + // MARK: - MatchField + + /// The plugin attribute a ``CategoryMapping`` is tested against. + enum MatchField: String, Codable, Sendable { + case bundleIDPrefix + case vendor + case nameKeyword + } + + let match: String + let field: MatchField + let category: PluginCategory +} + +// MARK: - PluginCategoryClassifier + +/// Classifies audio plugins into a ``PluginCategory`` using an ordered set of +/// rules (bundle-id prefix, exact vendor, name keyword) with a built-in keyword +/// heuristic as a final fallback. +struct PluginCategoryClassifier: Sendable { + + // MARK: - Properties + + private let mappings: [CategoryMapping] + + // MARK: - Initialization + + init(mappings: [CategoryMapping]) { + self.mappings = mappings + } + + // MARK: - Loading + + /// Loads mappings from `plugin_categories.json` in the app bundle, falling + /// back to ``defaultMappings`` when the file is missing or undecodable. + static func loadFromBundle() -> PluginCategoryClassifier { + guard let url = Bundle.main.url(forResource: "plugin_categories", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([CategoryMapping].self, from: data) else { + return PluginCategoryClassifier(mappings: defaultMappings) + } + return PluginCategoryClassifier(mappings: decoded) + } + + // MARK: - Classification + + /// Resolves the best ``PluginCategory`` for a plugin. + /// + /// Resolution order: + /// 1. Longest matching `bundleIDPrefix` (case-insensitive `hasPrefix`). + /// 2. Exact `vendor` match (case-insensitive). + /// 3. First `nameKeyword` contained in `name` (case-insensitive). + /// 4. Built-in keyword heuristic on `name`. + /// 5. ``PluginCategory/uncategorized`` when nothing matches. + func classify(bundleID: String, vendorName: String, name: String) -> PluginCategory { + let loweredBundleID = bundleID.lowercased() + let loweredVendor = vendorName.lowercased() + let loweredName = name.lowercased() + + // 1. Longest matching bundle-id prefix wins. + if let category = longestBundleIDPrefixMatch(loweredBundleID: loweredBundleID) { + return category + } + + // 2. Exact vendor match. + if !loweredVendor.isEmpty { + for mapping in mappings where mapping.field == .vendor { + if mapping.match.lowercased() == loweredVendor { + return mapping.category + } + } + } + + // 3. First name-keyword contained in the plugin name. + for mapping in mappings where mapping.field == .nameKeyword { + let keyword = mapping.match.lowercased() + if !keyword.isEmpty, loweredName.contains(keyword) { + return mapping.category + } + } + + // 4. Built-in keyword heuristic. + if let category = Self.heuristicCategory(forLoweredName: loweredName) { + return category + } + + // 5. Nothing matched. + return .uncategorized + } + + // MARK: - Private Helpers + + /// Returns the category of the longest `bundleIDPrefix` mapping whose match + /// is a case-insensitive prefix of the bundle identifier, or `nil`. + private func longestBundleIDPrefixMatch(loweredBundleID: String) -> PluginCategory? { + guard !loweredBundleID.isEmpty else { return nil } + var bestCategory: PluginCategory? + var bestLength = 0 + for mapping in mappings where mapping.field == .bundleIDPrefix { + let prefix = mapping.match.lowercased() + guard !prefix.isEmpty, loweredBundleID.hasPrefix(prefix) else { continue } + if prefix.count > bestLength { + bestLength = prefix.count + bestCategory = mapping.category + } + } + return bestCategory + } + + /// A small, order-sensitive keyword heuristic applied to a lowercased name. + private static func heuristicCategory(forLoweredName name: String) -> PluginCategory? { + for entry in heuristicKeywords where name.contains(entry.keyword) { + return entry.category + } + return nil + } + + /// Ordered heuristic keywords. Earlier entries take precedence when several + /// of these substrings appear in the same name. + private static let heuristicKeywords: [(keyword: String, category: PluginCategory)] = [ + ("reverb", .reverb), + ("delay", .delay), + ("equali", .eq), + ("eq", .eq), + ("comp", .compressor), + ("limit", .dynamics), + ("gate", .dynamics), + ("saturat", .saturation), + ("tape", .saturation), + ("distort", .distortion), + ("chorus", .modulation), + ("flanger", .modulation), + ("phaser", .modulation), + ("synth", .synth), + ("piano", .synth), + ("keys", .synth), + ("drum", .drums), + ("meter", .metering), + ("loudness", .metering), + ("lufs", .metering), + ("analy", .analyzer), + ("spectrum", .analyzer), + ("amp", .guitarAmp), + ("cab", .guitarAmp), + ("master", .mastering), + ] + + // MARK: - Default Mappings + + /// Built-in starter mappings, used when the bundled JSON is unavailable. + /// Kept in sync with `Resources/plugin_categories.json`. + static let defaultMappings: [CategoryMapping] = [ + CategoryMapping(match: "com.fabfilter.Pro-Q", field: .bundleIDPrefix, category: .eq), + CategoryMapping(match: "com.fabfilter.Pro-C", field: .bundleIDPrefix, category: .compressor), + CategoryMapping(match: "com.fabfilter.Pro-DS", field: .bundleIDPrefix, category: .dynamics), + CategoryMapping(match: "com.fabfilter.Pro-G", field: .bundleIDPrefix, category: .dynamics), + CategoryMapping(match: "com.fabfilter.Pro-MB", field: .bundleIDPrefix, category: .compressor), + CategoryMapping(match: "com.fabfilter.Pro-L", field: .bundleIDPrefix, category: .dynamics), + CategoryMapping(match: "com.fabfilter.Pro-R", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.fabfilter.Saturn", field: .bundleIDPrefix, category: .saturation), + CategoryMapping(match: "com.fabfilter.Timeless", field: .bundleIDPrefix, category: .delay), + CategoryMapping(match: "com.fabfilter.Volcano", field: .bundleIDPrefix, category: .filter), + CategoryMapping(match: "com.fabfilter.Simplon", field: .bundleIDPrefix, category: .filter), + CategoryMapping(match: "com.fabfilter.Twin", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.izotope.Ozone", field: .bundleIDPrefix, category: .mastering), + CategoryMapping(match: "com.izotope.Insight", field: .bundleIDPrefix, category: .metering), + CategoryMapping(match: "com.izotope.Neutron", field: .bundleIDPrefix, category: .channelStrip), + CategoryMapping(match: "com.izotope.Nectar", field: .bundleIDPrefix, category: .channelStrip), + CategoryMapping(match: "com.izotope.RX", field: .bundleIDPrefix, category: .noiseReduction), + CategoryMapping(match: "com.izotope.Trash", field: .bundleIDPrefix, category: .distortion), + CategoryMapping(match: "com.izotope.VocalSynth", field: .bundleIDPrefix, category: .creative), + CategoryMapping(match: "com.ValhallaDSP.", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.ValhallaDSP.ValhallaDelay", field: .bundleIDPrefix, category: .delay), + CategoryMapping(match: "com.ValhallaDSP.ValhallaFreqEcho", field: .bundleIDPrefix, category: .delay), + CategoryMapping(match: "com.ValhallaDSP.ValhallaSpaceModulator", field: .bundleIDPrefix, category: .modulation), + CategoryMapping(match: "com.ValhallaDSP.ValhallaSupermassive", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.soundtoys.", field: .bundleIDPrefix, category: .creative), + CategoryMapping(match: "com.soundtoys.EchoBoy", field: .bundleIDPrefix, category: .delay), + CategoryMapping(match: "com.soundtoys.Decapitator", field: .bundleIDPrefix, category: .saturation), + CategoryMapping(match: "com.soundtoys.PhaseMistress", field: .bundleIDPrefix, category: .modulation), + CategoryMapping(match: "com.soundtoys.MicroShift", field: .bundleIDPrefix, category: .pitch), + CategoryMapping(match: "com.native-instruments.Massive", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.native-instruments.Absynth", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.native-instruments.FM8", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.native-instruments.Kontakt", field: .bundleIDPrefix, category: .sampler), + CategoryMapping(match: "com.native-instruments.Battery", field: .bundleIDPrefix, category: .drums), + CategoryMapping(match: "com.native-instruments.GuitarRig", field: .bundleIDPrefix, category: .guitarAmp), + CategoryMapping(match: "com.xferrecords.serum", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.xferrecords.LFOTool", field: .bundleIDPrefix, category: .modulation), + CategoryMapping(match: "com.xferrecords.OTT", field: .bundleIDPrefix, category: .compressor), + CategoryMapping(match: "com.u-he.", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.u-he.Presswerk", field: .bundleIDPrefix, category: .compressor), + CategoryMapping(match: "com.u-he.Satin", field: .bundleIDPrefix, category: .saturation), + CategoryMapping(match: "com.u-he.ColourCopy", field: .bundleIDPrefix, category: .delay), + CategoryMapping(match: "com.spectrasonics.Omnisphere", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.spectrasonics.Keyscape", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.spectrasonics.Stylus", field: .bundleIDPrefix, category: .drums), + CategoryMapping(match: "com.oeksound.soothe", field: .bundleIDPrefix, category: .dynamics), + CategoryMapping(match: "com.oeksound.spiff", field: .bundleIDPrefix, category: .dynamics), + CategoryMapping(match: "com.antarestech.AutoTune", field: .bundleIDPrefix, category: .pitch), + CategoryMapping(match: "com.celemony.Melodyne", field: .bundleIDPrefix, category: .pitch), + CategoryMapping(match: "com.toguaudioline.", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.toguaudioline.TAL-Reverb", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.toontrack.EZdrummer", field: .bundleIDPrefix, category: .drums), + CategoryMapping(match: "com.toontrack.Superior", field: .bundleIDPrefix, category: .drums), + CategoryMapping(match: "com.xlnaudio.AddictiveDrums", field: .bundleIDPrefix, category: .drums), + CategoryMapping(match: "com.eventide.", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.eventide.Blackhole", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.arturia.", field: .bundleIDPrefix, category: .synth), + CategoryMapping(match: "com.cableguys.", field: .bundleIDPrefix, category: .modulation), + CategoryMapping(match: "com.kilohearts.", field: .bundleIDPrefix, category: .creative), + CategoryMapping(match: "com.output.", field: .bundleIDPrefix, category: .creative), + CategoryMapping(match: "com.softube.", field: .bundleIDPrefix, category: .saturation), + CategoryMapping(match: "com.slatedigital.", field: .bundleIDPrefix, category: .channelStrip), + CategoryMapping(match: "com.sonnox.", field: .bundleIDPrefix, category: .mastering), + CategoryMapping(match: "Arturia", field: .vendor, category: .synth), + CategoryMapping(match: "u-he", field: .vendor, category: .synth), + CategoryMapping(match: "Xfer Records", field: .vendor, category: .synth), + CategoryMapping(match: "Cableguys", field: .vendor, category: .modulation), + CategoryMapping(match: "Softube", field: .vendor, category: .saturation), + CategoryMapping(match: "Sonnox", field: .vendor, category: .mastering), + CategoryMapping(match: "Waves", field: .vendor, category: .dynamics), + CategoryMapping(match: "Slate Digital", field: .vendor, category: .channelStrip), + CategoryMapping(match: "Plugin Alliance", field: .vendor, category: .channelStrip), + CategoryMapping(match: "Spitfire Audio", field: .vendor, category: .sampler), + CategoryMapping(match: "Output", field: .vendor, category: .creative), + CategoryMapping(match: "Kilohearts", field: .vendor, category: .creative), + CategoryMapping(match: "Valhalla DSP", field: .vendor, category: .reverb), + CategoryMapping(match: "Newfangled Audio", field: .vendor, category: .mastering), + CategoryMapping(match: "MeldaProduction", field: .vendor, category: .utility), + CategoryMapping(match: "Tokyo Dawn Labs", field: .vendor, category: .dynamics), + CategoryMapping(match: "Klanghelm", field: .vendor, category: .compressor), + CategoryMapping(match: "Spectrasonics", field: .vendor, category: .synth), + CategoryMapping(match: "Toontrack", field: .vendor, category: .drums), + CategoryMapping(match: "reFX", field: .vendor, category: .synth), + CategoryMapping(match: "Roland", field: .vendor, category: .synth), + CategoryMapping(match: "KORG", field: .vendor, category: .synth), + CategoryMapping(match: "Antares", field: .vendor, category: .pitch), + CategoryMapping(match: "Celemony", field: .vendor, category: .pitch), + CategoryMapping(match: "Oeksound", field: .vendor, category: .dynamics), + CategoryMapping(match: "de-esser", field: .nameKeyword, category: .dynamics), + CategoryMapping(match: "transient", field: .nameKeyword, category: .dynamics), + CategoryMapping(match: "maximizer", field: .nameKeyword, category: .mastering), + CategoryMapping(match: "exciter", field: .nameKeyword, category: .saturation), + CategoryMapping(match: "bitcrush", field: .nameKeyword, category: .creative), + CategoryMapping(match: "vocoder", field: .nameKeyword, category: .creative), + CategoryMapping(match: "convolution", field: .nameKeyword, category: .reverb), + ] +} diff --git a/PluginUpdater/Pluginventory/Services/Offload/OffloadManager.swift b/PluginUpdater/Pluginventory/Services/Offload/OffloadManager.swift new file mode 100644 index 0000000..f45a9e6 --- /dev/null +++ b/PluginUpdater/Pluginventory/Services/Offload/OffloadManager.swift @@ -0,0 +1,182 @@ +import Foundation + +/// A durable record of a single offloaded plugin bundle. +/// +/// Offloading *moves* a plugin bundle out of its install location into a separate +/// offload folder to reclaim disk space. Everything needed to move the bundle back +/// to exactly where it came from is captured here, so the record can be persisted +/// (it is `Codable`) and later handed back to ``OffloadManager/restore(_:)``. +struct OffloadRecord: Codable, Sendable, Equatable { + + /// Absolute path the bundle occupied before it was offloaded. This is the restore target. + let originalPath: String + + /// Absolute path the bundle currently occupies inside the offload folder. + let offloadedPath: String + + /// The bundle's file name (e.g. `"Pro-Q 3.vst3"`) — the source's last path component. + let bundleName: String + + /// Size of the bundle in bytes, measured at the moment it was offloaded. + let sizeBytes: Int64 + + /// When the bundle was offloaded. + let offloadedAt: Date +} + +/// Moves plugin bundles into a separate offload folder to reclaim disk space, and +/// moves them back on demand. +/// +/// This mirrors the offloading feature of apps like Plugoff: a bundle is *moved* +/// (not copied) into an offload root, freeing its space on the original volume, and +/// can later be *moved* back to precisely its original location. Because every +/// operation touches the filesystem, the type is an `actor` so its work stays off +/// the main thread and concurrent calls are serialized. +actor OffloadManager { + + // MARK: - Errors + + /// Errors thrown while offloading or restoring a bundle. + enum OffloadError: Error, LocalizedError { + /// The bundle to offload does not exist at the given path. + case sourceMissing(String) + /// A file already occupies the offload destination path. + case alreadyExists(String) + /// The offloaded bundle is no longer present at its recorded offload path. + case notOffloaded(String) + /// Restoring would overwrite a file already at the original location. + case restoreTargetOccupied(String) + /// An underlying filesystem operation failed. + case ioFailure(String) + + var errorDescription: String? { + switch self { + case .sourceMissing(let path): + return "The plugin bundle to offload does not exist at \(path)." + case .alreadyExists(let path): + return "A file already exists at the offload destination \(path)." + case .notOffloaded(let path): + return "The offloaded bundle is no longer present at \(path)." + case .restoreTargetOccupied(let path): + return "Cannot restore: a file already exists at the original location \(path)." + case .ioFailure(let message): + return "A file operation failed: \(message)" + } + } + } + + // MARK: - Properties + + /// The folder that offloaded bundles are moved into. + private let offloadRoot: URL + + // MARK: - Initialization + + /// Creates a manager that offloads bundles into the given root folder. + /// - Parameter offloadRoot: Destination folder for offloaded bundles + /// (for example `~/PluginventoryOffload`). It is created lazily on first offload. + init(offloadRoot: URL) { + self.offloadRoot = offloadRoot + } + + /// Creates a manager that offloads into `~/PluginventoryOffload`. + init() { + self.offloadRoot = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("PluginventoryOffload", isDirectory: true) + } + + // MARK: - Offloading + + /// Moves the bundle at `sourceURL` into the offload root, freeing its original space. + /// + /// The bundle's size is measured *before* the move, since afterwards the source no + /// longer exists. The offload root is created if necessary. If a file already + /// occupies the destination the move is refused, so nothing is clobbered. + /// + /// - Parameter sourceURL: Filesystem location of the bundle to offload. + /// - Returns: A record describing the move, suitable for persistence and later restore. + /// - Throws: ``OffloadError/sourceMissing(_:)`` if the bundle is absent, + /// ``OffloadError/alreadyExists(_:)`` if the destination is occupied, or + /// ``OffloadError/ioFailure(_:)`` if the filesystem move fails. + func offload(bundleAt sourceURL: URL) throws -> OffloadRecord { + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw OffloadError.sourceMissing(sourceURL.path) + } + + // Measure while the bundle still exists; the move erases the source. + let sizeBytes = BundleSizeService.directorySize(at: sourceURL) + let bundleName = sourceURL.lastPathComponent + + do { + try fileManager.createDirectory(at: offloadRoot, withIntermediateDirectories: true) + } catch { + throw OffloadError.ioFailure(error.localizedDescription) + } + + let destination = offloadRoot.appendingPathComponent(bundleName) + + guard !fileManager.fileExists(atPath: destination.path) else { + throw OffloadError.alreadyExists(destination.path) + } + + do { + try fileManager.moveItem(at: sourceURL, to: destination) + } catch { + throw OffloadError.ioFailure(error.localizedDescription) + } + + return OffloadRecord( + originalPath: sourceURL.path, + offloadedPath: destination.path, + bundleName: bundleName, + sizeBytes: sizeBytes, + offloadedAt: Date() + ) + } + + // MARK: - Restoring + + /// Moves a previously offloaded bundle back to its original location. + /// + /// The original location's parent directory is recreated if it went missing while + /// the bundle was offloaded. If a file already occupies the original location the + /// restore is refused, so nothing is clobbered. + /// + /// - Parameter record: The record returned by ``offload(bundleAt:)``. + /// - Throws: ``OffloadError/notOffloaded(_:)`` if the offloaded copy is gone, + /// ``OffloadError/restoreTargetOccupied(_:)`` if the original location is taken, + /// or ``OffloadError/ioFailure(_:)`` if the filesystem move fails. + func restore(_ record: OffloadRecord) throws { + let fileManager = FileManager.default + let source = URL(fileURLWithPath: record.offloadedPath) + let target = URL(fileURLWithPath: record.originalPath) + + guard fileManager.fileExists(atPath: source.path) else { + throw OffloadError.notOffloaded(record.offloadedPath) + } + + guard !fileManager.fileExists(atPath: target.path) else { + throw OffloadError.restoreTargetOccupied(target.path) + } + + let parent = target.deletingLastPathComponent() + + do { + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + try fileManager.moveItem(at: source, to: target) + } catch { + throw OffloadError.ioFailure(error.localizedDescription) + } + } + + // MARK: - Queries + + /// Whether the offloaded copy described by `record` currently exists on disk. + /// - Parameter record: The record to check. + /// - Returns: `true` if a file exists at the recorded offload path. + func isOffloaded(_ record: OffloadRecord) -> Bool { + FileManager.default.fileExists(atPath: record.offloadedPath) + } +} diff --git a/PluginUpdater/Pluginventory/Services/Persistence/PersistenceController.swift b/PluginUpdater/Pluginventory/Services/Persistence/PersistenceController.swift index 205053f..a447197 100644 --- a/PluginUpdater/Pluginventory/Services/Persistence/PersistenceController.swift +++ b/PluginUpdater/Pluginventory/Services/Persistence/PersistenceController.swift @@ -100,6 +100,7 @@ enum PersistenceController { ScanLocation.self, AbletonProject.self, AbletonProjectPlugin.self, + OffloadedPlugin.self, ]) static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { diff --git a/PluginUpdater/Pluginventory/Services/Persistence/PluginReconciler.swift b/PluginUpdater/Pluginventory/Services/Persistence/PluginReconciler.swift index 398bb9f..b9f84b8 100644 --- a/PluginUpdater/Pluginventory/Services/Persistence/PluginReconciler.swift +++ b/PluginUpdater/Pluginventory/Services/Persistence/PluginReconciler.swift @@ -4,6 +4,8 @@ import SwiftData @ModelActor actor PluginReconciler { + private static let categoryClassifier = PluginCategoryClassifier.loadFromBundle() + struct ReconciliationResult: Sendable { let newPlugins: Int let updatedPlugins: Int @@ -149,6 +151,11 @@ actor PluginReconciler { existing.fileSize = size } existing.fileCreationDate = metadata.fileCreationDate + existing.category = Self.categoryClassifier.classify( + bundleID: metadata.bundleIdentifier, + vendorName: metadata.vendorName, + name: metadata.name + ) // Plugin reappeared after being removed if existing.isRemoved { @@ -204,6 +211,11 @@ actor PluginReconciler { plugin.fileSize = size } plugin.fileCreationDate = metadata.fileCreationDate + plugin.category = Self.categoryClassifier.classify( + bundleID: metadata.bundleIdentifier, + vendorName: metadata.vendorName, + name: metadata.name + ) // Initial version record let initialVersion = PluginVersion(version: metadata.version) diff --git a/PluginUpdater/Pluginventory/Views/Components/RosettaWarningBanner.swift b/PluginUpdater/Pluginventory/Views/Components/RosettaWarningBanner.swift new file mode 100644 index 0000000..512f802 --- /dev/null +++ b/PluginUpdater/Pluginventory/Views/Components/RosettaWarningBanner.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// A warning banner shown when the library contains plugins that will stop +/// working once macOS 28 removes Rosetta 2 (Intel-only or legacy binaries). +/// +/// Mirrors the prominent macOS 28 / Rosetta notice in PluginHub. +struct RosettaWarningBanner: View { + let count: Int + let onView: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.white) + VStack(alignment: .leading, spacing: 1) { + Text("^[\(count) plugin](inflect: true) won't work in macOS 28") + .font(.callout.weight(.semibold)) + .foregroundStyle(.white) + Text("Apple is retiring Rosetta, so these Intel-only plugins stop working when you update.") + .font(.caption) + .foregroundStyle(.white.opacity(0.9)) + .lineLimit(1) + } + Spacer() + Button("View", action: onView) + .buttonStyle(.bordered) + .tint(.white) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color(red: 0.62, green: 0.16, blue: 0.14)) + } +} diff --git a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift index b7344b2..b301f46 100644 --- a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift +++ b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift @@ -11,6 +11,10 @@ enum SidebarFilter: Hashable { case projectsMissingPlugins case usedPlugins case unusedPlugins + case category(PluginCategory) + case duplicates + case rosettaRisk + case offloaded } /// Wraps a Plugin with its computed update status so the Table can sort all columns. @@ -55,8 +59,12 @@ private struct SidebarCounts { var projectsMissingPlugins = 0 var usedPlugins = 0 var unusedPlugins = 0 + var categoryCounts: [PluginCategory: Int] = [:] + var duplicates = 0 + var rosettaRisk = 0 func count(for format: PluginFormat) -> Int { formatCounts[format, default: 0] } + func count(for category: PluginCategory) -> Int { categoryCounts[category, default: 0] } } @MainActor @@ -66,6 +74,7 @@ struct DashboardView: View { @Query(filter: #Predicate { !$0.isRemoved }) private var plugins: [Plugin] @Query(filter: #Predicate { !$0.isRemoved }) private var abletonProjects: [AbletonProject] + @Query private var offloadedPlugins: [OffloadedPlugin] @State private var sidebarSelection: SidebarFilter = .all @State private var searchText = "" @State private var debouncedSearchText = "" @@ -74,6 +83,8 @@ struct DashboardView: View { @State private var selectedPluginIDs: Set = [] @State private var showInspector = false @State private var selectedProjectForDetail: AbletonProject? + @State private var showInsights = false + @State private var pendingOffloadIDs: Set? /// Maps persisted column name strings to their KeyPathComparator. private static let pluginColumnMap: [String: PartialKeyPath] = [ @@ -123,7 +134,7 @@ struct DashboardView: View { // MARK: - Computed helpers /// Single-pass sidebar counts — replaces 6+ separate filter iterations. - private func computeSidebarCounts(manifest: [String: UpdateManifestEntry]) -> SidebarCounts { + private func computeSidebarCounts(manifest: [String: UpdateManifestEntry], duplicateReport: DuplicateReport) -> SidebarCounts { var c = SidebarCounts() for plugin in plugins { if plugin.isHidden { @@ -131,6 +142,13 @@ struct DashboardView: View { } else { c.visible += 1 c.formatCounts[plugin.format, default: 0] += 1 + c.categoryCounts[plugin.category, default: 0] += 1 + if ArchitectureCompatibility.breaksInMacOS28(plugin.architectures) { + c.rosettaRisk += 1 + } + if duplicateReport.duplicatePaths.contains(plugin.path) { + c.duplicates += 1 + } if let entry = manifest[plugin.bundleIdentifier], !entry.latestVersion.isEmpty, entry.latestVersion.isNewerVersion(than: plugin.currentVersion) { @@ -162,7 +180,7 @@ struct DashboardView: View { } /// Filtered + sorted rows for the Table, computed once per body evaluation. - private func computeRows(manifest: [String: UpdateManifestEntry], projectCounts: [String: Int], instanceCounts: [String: Int]) -> [PluginRow] { + private func computeRows(manifest: [String: UpdateManifestEntry], projectCounts: [String: Int], instanceCounts: [String: Int], duplicateReport: DuplicateReport) -> [PluginRow] { var result = plugins if sidebarSelection == .hidden { @@ -188,7 +206,13 @@ struct DashboardView: View { case .unusedPlugins: let usedNames = collectUsedPluginNames() result = result.filter { !usedNames.contains($0.name.lowercased()) } - case .allProjects, .projectsMissingPlugins: + case .category(let category): + result = result.filter { $0.category == category } + case .duplicates: + result = result.filter { duplicateReport.duplicatePaths.contains($0.path) } + case .rosettaRisk: + result = result.filter { ArchitectureCompatibility.breaksInMacOS28($0.architectures) } + case .allProjects, .projectsMissingPlugins, .offloaded: break } } @@ -326,14 +350,103 @@ struct DashboardView: View { return (projectCounts, instanceCounts) } + /// Builds the duplicate report over currently-visible (non-hidden) plugins. + private func computeDuplicateReport() -> DuplicateReport { + let inputs = plugins.compactMap { plugin -> DuplicatePluginInput? in + plugin.isHidden ? nil : DuplicatePluginInput( + name: plugin.name, + vendorName: plugin.vendorName, + bundleIdentifier: plugin.bundleIdentifier, + format: plugin.format, + path: plugin.path + ) + } + return DuplicateDetector().analyze(inputs) + } + + /// Builds insights analytics data over currently-visible (non-hidden) plugins. + private func buildInsightsData() -> InsightsData { + let inputs = plugins.compactMap { plugin -> InsightPlugin? in + plugin.isHidden ? nil : InsightPlugin( + name: plugin.name, + vendorName: plugin.vendorName, + format: plugin.format, + category: plugin.category, + fileSize: plugin.fileSize + ) + } + return InsightsData.build(from: inputs) + } + + private func categoryIcon(_ category: PluginCategory) -> String { + switch category { + case .synth, .sampler: return "pianokeys" + case .drums: return "metronome" + case .eq, .filter: return "slider.horizontal.3" + case .dynamics, .compressor: return "waveform.path" + case .saturation, .distortion: return "flame" + case .reverb, .delay: return "wave.3.right" + case .modulation, .creative: return "sparkles" + case .pitch: return "tuningfork" + case .mastering, .channelStrip: return "slider.vertical.3" + case .metering, .analyzer: return "waveform" + case .utility: return "wrench.and.screwdriver" + case .guitarAmp: return "guitars" + case .noiseReduction: return "speaker.slash" + case .uncategorized: return "square.dashed" + } + } + + private var offloadDialogTitle: String { + let n = pendingOffloadIDs?.count ?? 0 + return "Offload \(n) plugin\(n == 1 ? "" : "s")?" + } + + private var offloadedSizeDisplay: String { + let total = offloadedPlugins.reduce(Int64(0)) { $0 + $1.sizeBytes } + return ByteCountFormatter.string(fromByteCount: total, countStyle: .file) + } + + @ViewBuilder + private func offloadedListDetail() -> some View { + List { + Section { + ForEach(offloadedPlugins.sorted { $0.offloadedAt > $1.offloadedAt }) { item in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + Text("\(item.vendorName) · \(item.format?.displayName ?? "") · \(item.sizeDisplay)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Restore") { + Task { await appState.restoreOffloaded([item]) } + } + .buttonStyle(.bordered) + } + .padding(.vertical, 2) + } + } header: { + Text("\(offloadedPlugins.count) offloaded · \(offloadedSizeDisplay) reclaimed") + } + } + .overlay { + if offloadedPlugins.isEmpty { + ContentUnavailableView("Nothing Offloaded", systemImage: "archivebox", description: Text("Right-click plugins and choose Offload to reclaim disk space.")) + } + } + } + // MARK: - Body var body: some View { // Compute once per body evaluation — reused by Table, overlay, and sidebar. let manifest = appState.manifestEntries - let counts = computeSidebarCounts(manifest: manifest) + let duplicateReport = computeDuplicateReport() + let counts = computeSidebarCounts(manifest: manifest, duplicateReport: duplicateReport) let usageData = computePluginUsageData() - let rows = computeRows(manifest: manifest, projectCounts: usageData.projects, instanceCounts: usageData.instances) + let rows = computeRows(manifest: manifest, projectCounts: usageData.projects, instanceCounts: usageData.instances, duplicateReport: duplicateReport) NavigationSplitView { List(selection: $sidebarSelection) { @@ -350,10 +463,38 @@ struct DashboardView: View { .tag(SidebarFilter.format(format)) } } + if counts.rosettaRisk > 0 { + Section("Compatibility") { + Label("Won't run in macOS 28 (\(counts.rosettaRisk))", systemImage: "exclamationmark.triangle.fill") + .tag(SidebarFilter.rosettaRisk) + .foregroundStyle(.orange) + } + } + Section("Categories") { + ForEach(PluginCategory.allCases) { category in + let categoryCount = counts.count(for: category) + if categoryCount > 0 { + Label("\(category.displayName) (\(categoryCount))", systemImage: categoryIcon(category)) + .tag(SidebarFilter.category(category)) + } + } + } + if counts.duplicates > 0 { + Section("Cleanup") { + Label("Duplicates (\(counts.duplicates))", systemImage: "square.on.square") + .tag(SidebarFilter.duplicates) + } + } Section("Manage") { Label("Hidden (\(counts.hidden))", systemImage: "eye.slash") .tag(SidebarFilter.hidden) } + if !offloadedPlugins.isEmpty { + Section("Storage") { + Label("Offloaded (\(offloadedPlugins.count)) · \(offloadedSizeDisplay) saved", systemImage: "archivebox") + .tag(SidebarFilter.offloaded) + } + } if counts.totalProjects > 0 || appState.isProjectScanning || !appState.projectScanDirectories().isEmpty { Section("Projects") { Label("All Projects (\(counts.totalProjects))", systemImage: "doc.text") @@ -391,8 +532,10 @@ struct DashboardView: View { projectListDetail( projects: abletonProjects.filter { $0.missingPluginCount > 0 } ) + case .offloaded: + offloadedListDetail() default: - pluginTableDetail(rows: rows, manifest: manifest) + pluginTableDetail(rows: rows, manifest: manifest, rosettaRiskCount: counts.rosettaRisk) } } .frame(minWidth: 700, minHeight: 400) @@ -457,7 +600,48 @@ struct DashboardView: View { // MARK: - Plugin Table Detail @ViewBuilder - private func pluginTableDetail(rows: [PluginRow], manifest: [String: UpdateManifestEntry]) -> some View { + private func pluginTableDetail(rows: [PluginRow], manifest: [String: UpdateManifestEntry], rosettaRiskCount: Int) -> some View { + pluginTable(rows: rows, manifest: manifest) + .background(NSTableViewFinder.enableColumnAutoResize()) + .id(sidebarSelection) + .safeAreaInset(edge: .top) { + rosettaBanner(rosettaRiskCount: rosettaRiskCount) + } + .safeAreaInset(edge: .bottom) { + statusFooter(rowCount: rows.count) + } + .toolbar { + pluginTableToolbar() + } + .confirmationDialog( + offloadDialogTitle, + isPresented: offloadDialogBinding, + presenting: pendingOffloadIDs + ) { ids in + offloadDialogActions(ids) + } message: { _ in + Text("Selected bundles will be moved to ~/PluginventoryOffload to reclaim disk space. You can restore them anytime from the Offloaded list.") + } + .sheet(isPresented: $showInsights) { + NavigationStack { + InsightsView(data: buildInsightsData()) + } + } + .inspector(isPresented: $showInspector) { + inspectorContent() + } + .onChange(of: searchText) { _, newValue in + handleSearchChange(newValue) + } + .onChange(of: selectedPluginIDs) { _, newValue in + if !newValue.isEmpty { + showInspector = true + } + } + } + + @ViewBuilder + private func pluginTable(rows: [PluginRow], manifest: [String: UpdateManifestEntry]) -> some View { Table(rows, selection: $selectedPluginIDs, sortOrder: $sortOrder) { Group { TableColumn("Name", value: \PluginRow.name) { (row: PluginRow) in @@ -526,124 +710,166 @@ struct DashboardView: View { .width(min: 50, ideal: 70, max: 90) } } - .background(NSTableViewFinder.enableColumnAutoResize()) - .id(sidebarSelection) .contextMenu(forSelectionType: PersistentIdentifier.self) { ids in - if !ids.isEmpty { - let count = ids.count - Button("Copy Path\(count > 1 ? "s" : "")") { - copyPaths(for: ids) - } - Button("Copy Full Details") { - copyFullDetails(for: ids, manifest: manifest) - } - - Divider() + rowContextMenu(ids, manifest: manifest) + } + .overlay { + tableEmptyOverlay(rows: rows) + } + } - Button("Reveal in Finder") { - revealInFinder(ids: ids) - } - Button("Open Publisher Website") { - openVendorWebsites(for: ids, manifest: manifest) + @ViewBuilder + private func rowContextMenu(_ ids: Set, manifest: [String: UpdateManifestEntry]) -> some View { + if !ids.isEmpty { + let count = ids.count + Button("Copy Path\(count > 1 ? "s" : "")") { + copyPaths(for: ids) + } + Button("Copy Full Details") { + copyFullDetails(for: ids, manifest: manifest) + } + Divider() + Button("Reveal in Finder") { + revealInFinder(ids: ids) + } + Button("Open Publisher Website") { + openVendorWebsites(for: ids, manifest: manifest) + } + Divider() + Button("Offload to Reclaim Space") { + pendingOffloadIDs = ids + } + Divider() + if sidebarSelection == .hidden { + Button("Unhide\(count > 1 ? " \(count) Plugins" : " Plugin")") { + setHidden(false, for: ids) } - - Divider() - - if sidebarSelection == .hidden { - Button("Unhide\(count > 1 ? " \(count) Plugins" : " Plugin")") { - setHidden(false, for: ids) - } - } else { - Button("Hide\(count > 1 ? " \(count) Plugins" : " Plugin")") { - setHidden(true, for: ids) - } + } else { + Button("Hide\(count > 1 ? " \(count) Plugins" : " Plugin")") { + setHidden(true, for: ids) } } } - .overlay { - if plugins.isEmpty && !appState.isScanning { - ContentUnavailableView("No Plugins Found", systemImage: "puzzlepiece.extension", description: Text("Run a scan to discover your audio plugins.")) - } else if rows.isEmpty && !debouncedSearchText.isEmpty { - ContentUnavailableView.search(text: debouncedSearchText) - } else if rows.isEmpty && sidebarSelection == .hidden { - ContentUnavailableView("No Hidden Plugins", systemImage: "eye.slash", description: Text("Right-click a plugin and choose Hide to hide it here.")) + } + + @ViewBuilder + private func tableEmptyOverlay(rows: [PluginRow]) -> some View { + if plugins.isEmpty && !appState.isScanning { + ContentUnavailableView("No Plugins Found", systemImage: "puzzlepiece.extension", description: Text("Run a scan to discover your audio plugins.")) + } else if rows.isEmpty && !debouncedSearchText.isEmpty { + ContentUnavailableView.search(text: debouncedSearchText) + } else if rows.isEmpty && sidebarSelection == .hidden { + ContentUnavailableView("No Hidden Plugins", systemImage: "eye.slash", description: Text("Right-click a plugin and choose Hide to hide it here.")) + } + } + + @ViewBuilder + private func rosettaBanner(rosettaRiskCount: Int) -> some View { + if rosettaRiskCount > 0 && sidebarSelection != .rosettaRisk { + RosettaWarningBanner(count: rosettaRiskCount) { + sidebarSelection = .rosettaRisk } } - .safeAreaInset(edge: .bottom) { - HStack { - Text(statusBarText(rowCount: rows.count)) + } + + @ViewBuilder + private func statusFooter(rowCount: Int) -> some View { + HStack { + Text(statusBarText(rowCount: rowCount)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let lastRefresh = appState.lastManifestRefresh { + Text("Updates checked \(lastRefresh, format: .relative(presentation: .named))") .font(.caption) .foregroundStyle(.secondary) - Spacer() - if let lastRefresh = appState.lastManifestRefresh { - Text("Updates checked \(lastRefresh, format: .relative(presentation: .named))") - .font(.caption) - .foregroundStyle(.secondary) - } } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.bar) } - .toolbar { - ToolbarItem(placement: .principal) { - if appState.isScanning { - ProgressView(value: appState.scanProgress) - .progressViewStyle(.circular) - .controlSize(.regular) - } else { - Button { - Task { await appState.performScan() } - } label: { - HStack(spacing: 6) { - Text(statusSubtitle) - .font(.caption) - Image(systemName: "arrow.clockwise") - } - .padding(.horizontal, 12) - .padding(.vertical, 6) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.bar) + } + + @ToolbarContentBuilder + private func pluginTableToolbar() -> some ToolbarContent { + ToolbarItem(placement: .principal) { + if appState.isScanning { + ProgressView(value: appState.scanProgress) + .progressViewStyle(.circular) + .controlSize(.regular) + } else { + Button { + Task { await appState.performScan() } + } label: { + HStack(spacing: 6) { + Text(statusSubtitle) + .font(.caption) + Image(systemName: "arrow.clockwise") } + .padding(.horizontal, 12) + .padding(.vertical, 6) } } - ToolbarItem(placement: .primaryAction) { - HStack(spacing: 8) { - TextField("Search plugins or vendors", text: $searchText) - .textFieldStyle(.roundedBorder) - .frame(minWidth: 180, idealWidth: 250) - Button { - showInspector.toggle() - } label: { - Label("Info", systemImage: "sidebar.trailing") - } - .labelStyle(.titleAndIcon) + } + ToolbarItem(placement: .primaryAction) { + HStack(spacing: 8) { + TextField("Search plugins or vendors", text: $searchText) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 180, idealWidth: 250) + Button { + showInsights = true + } label: { + Label("Insights", systemImage: "chart.bar.xaxis") + } + .labelStyle(.titleAndIcon) + Button { + showInspector.toggle() + } label: { + Label("Info", systemImage: "sidebar.trailing") } + .labelStyle(.titleAndIcon) } } - .inspector(isPresented: $showInspector) { - if let plugin = selectedPlugin { - PluginDetailView(plugin: plugin, manifest: appState.manifestEntries) - .inspectorColumnWidth(min: 280, ideal: 320, max: 400) - } else { - ContentUnavailableView("No Selection", systemImage: "cursorarrow.click", description: Text("Select a plugin to view its details.")) - .inspectorColumnWidth(min: 280, ideal: 320, max: 400) - } + } + + @ViewBuilder + private func offloadDialogActions(_ ids: Set) -> some View { + Button("Offload", role: .destructive) { + let toOffload = plugins(for: ids) + pendingOffloadIDs = nil + Task { await appState.offloadPlugins(toOffload) } } - .onChange(of: searchText) { _, newValue in - searchTask?.cancel() - if newValue.isEmpty { - debouncedSearchText = "" - } else { - searchTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) - if !Task.isCancelled { - debouncedSearchText = newValue - } - } - } + Button("Cancel", role: .cancel) { pendingOffloadIDs = nil } + } + + private var offloadDialogBinding: Binding { + Binding( + get: { pendingOffloadIDs != nil }, + set: { if !$0 { pendingOffloadIDs = nil } } + ) + } + + @ViewBuilder + private func inspectorContent() -> some View { + if let plugin = selectedPlugin { + PluginDetailView(plugin: plugin, manifest: appState.manifestEntries) + .inspectorColumnWidth(min: 280, ideal: 320, max: 400) + } else { + ContentUnavailableView("No Selection", systemImage: "cursorarrow.click", description: Text("Select a plugin to view its details.")) + .inspectorColumnWidth(min: 280, ideal: 320, max: 400) } - .onChange(of: selectedPluginIDs) { _, newValue in - if !newValue.isEmpty { - showInspector = true + } + + private func handleSearchChange(_ newValue: String) { + searchTask?.cancel() + if newValue.isEmpty { + debouncedSearchText = "" + } else { + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + if !Task.isCancelled { + debouncedSearchText = newValue + } } } } diff --git a/PluginUpdater/Pluginventory/Views/Dashboard/InsightsView.swift b/PluginUpdater/Pluginventory/Views/Dashboard/InsightsView.swift new file mode 100644 index 0000000..960e36d --- /dev/null +++ b/PluginUpdater/Pluginventory/Views/Dashboard/InsightsView.swift @@ -0,0 +1,120 @@ +import SwiftUI +import Charts + +/// A library-analytics view (Plugoff-style "Insights"): overview totals plus +/// breakdowns by format, category, and manufacturer. +struct InsightsView: View { + let data: InsightsData + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + statTiles + if !data.formatCounts.isEmpty { + chartCard(title: "Formats") { + Chart(data.formatCounts) { item in + BarMark( + x: .value("Count", item.count), + y: .value("Format", item.format.displayName) + ) + .foregroundStyle(.blue) + .annotation(position: .trailing, alignment: .leading) { + Text("\(item.count)").font(.caption2).foregroundStyle(.secondary) + } + } + .chartXAxis(.hidden) + .frame(height: chartHeight(data.formatCounts.count)) + } + } + if !data.categoryCounts.isEmpty { + chartCard(title: "Categories") { + Chart(data.categoryCounts) { item in + BarMark( + x: .value("Count", item.count), + y: .value("Category", item.category.displayName) + ) + .foregroundStyle(.green) + .annotation(position: .trailing, alignment: .leading) { + Text("\(item.count)").font(.caption2).foregroundStyle(.secondary) + } + } + .chartXAxis(.hidden) + .frame(height: chartHeight(data.categoryCounts.count)) + } + } + if !data.manufacturersRanked.isEmpty { + chartCard(title: "Top Manufacturers") { + Chart(data.topManufacturers(12)) { item in + BarMark( + x: .value("Count", item.count), + y: .value("Manufacturer", item.name) + ) + .foregroundStyle(.purple) + .annotation(position: .trailing, alignment: .leading) { + Text("\(item.count)").font(.caption2).foregroundStyle(.secondary) + } + } + .chartXAxis(.hidden) + .frame(height: chartHeight(min(data.manufacturersRanked.count, 12))) + } + } + } + .padding(20) + } + .frame(minWidth: 560, minHeight: 520) + .navigationTitle("Insights") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + + private func chartHeight(_ rows: Int) -> CGFloat { + max(80, CGFloat(rows) * 30) + } + + private var statTiles: some View { + HStack(spacing: 12) { + StatTile(title: "Plugins", value: "\(data.pluginCount)", systemImage: "puzzlepiece.extension") + StatTile(title: "Manufacturers", value: "\(data.manufacturerCount)", systemImage: "building.2") + StatTile( + title: "Total Size", + value: ByteCountFormatter.string(fromByteCount: data.totalSizeBytes, countStyle: .file), + systemImage: "internaldrive" + ) + } + } + + @ViewBuilder + private func chartCard(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.headline) + content() + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) + } +} + +private struct StatTile: View { + let title: String + let value: String + let systemImage: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Label(title, systemImage: systemImage) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.title2.weight(.semibold)) + .monospacedDigit() + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/ArchitectureCompatibilityTests.swift b/PluginUpdater/PluginventoryTests/Services/ArchitectureCompatibilityTests.swift new file mode 100644 index 0000000..c083788 --- /dev/null +++ b/PluginUpdater/PluginventoryTests/Services/ArchitectureCompatibilityTests.swift @@ -0,0 +1,71 @@ +import Testing +import Foundation +@testable import Pluginventory + +@Suite("ArchitectureCompatibility Tests") +struct ArchitectureCompatibilityTests { + + @Test("Universal binary is native, not Intel-only, and survives macOS 28") + func universalBinary() { + let archs: [CPUArchitecture] = [.arm64, .x86_64] + #expect(ArchitectureCompatibility.isUniversal(archs)) + #expect(!ArchitectureCompatibility.isIntelOnly(archs)) + #expect(!ArchitectureCompatibility.breaksInMacOS28(archs)) + #expect(ArchitectureCompatibility.compatibilityReason(archs) == nil) + } + + @Test("Intel-only binary requires Rosetta and breaks in macOS 28") + func intelOnlyBinary() throws { + let archs: [CPUArchitecture] = [.x86_64] + #expect(ArchitectureCompatibility.isIntelOnly(archs)) + #expect(ArchitectureCompatibility.requiresRosetta(archs)) + #expect(ArchitectureCompatibility.breaksInMacOS28(archs)) + let reason = try #require(ArchitectureCompatibility.compatibilityReason(archs)) + #expect(reason.contains("Rosetta")) + } + + @Test("Apple Silicon-only binary is native and survives macOS 28") + func appleSiliconOnlyBinary() { + let archs: [CPUArchitecture] = [.arm64] + #expect(ArchitectureCompatibility.isAppleSiliconNative(archs)) + #expect(!ArchitectureCompatibility.breaksInMacOS28(archs)) + } + + @Test("Legacy i386 binary breaks and is flagged as legacy") + func legacyIntel32Binary() throws { + let archs: [CPUArchitecture] = [.i386] + #expect(ArchitectureCompatibility.breaksInMacOS28(archs)) + let reason = try #require(ArchitectureCompatibility.compatibilityReason(archs)) + #expect(reason.contains("Legacy")) + } + + @Test("Legacy PowerPC binary breaks and is flagged as legacy") + func legacyPowerPCBinary() throws { + let archs: [CPUArchitecture] = [.ppc] + #expect(ArchitectureCompatibility.breaksInMacOS28(archs)) + let reason = try #require(ArchitectureCompatibility.compatibilityReason(archs)) + #expect(reason.contains("Legacy")) + } + + @Test("Empty slice list is indeterminate and does not break") + func emptySliceList() { + let archs: [CPUArchitecture] = [] + #expect(!ArchitectureCompatibility.breaksInMacOS28(archs)) + #expect(ArchitectureCompatibility.compatibilityReason(archs) == nil) + } + + @Test("Unknown-only slice list is indeterminate and does not break") + func unknownOnlySliceList() { + let archs: [CPUArchitecture] = [.unknown] + #expect(!ArchitectureCompatibility.breaksInMacOS28(archs)) + #expect(ArchitectureCompatibility.compatibilityReason(archs) == nil) + } + + @Test("Sunset summary counts only the plugins that break in macOS 28") + func sunsetSummaryCountsBrokenPlugins() { + let archLists: [[CPUArchitecture]] = [[.arm64, .x86_64], [.x86_64], [.arm64], [.i386]] + let summary = RosettaSunsetSummary.summarize(archLists) + #expect(summary.brokenCount == 2) + #expect(summary.total == 4) + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/DuplicateDetectorTests.swift b/PluginUpdater/PluginventoryTests/Services/DuplicateDetectorTests.swift new file mode 100644 index 0000000..68ca854 --- /dev/null +++ b/PluginUpdater/PluginventoryTests/Services/DuplicateDetectorTests.swift @@ -0,0 +1,84 @@ +import Testing +import Foundation +@testable import Pluginventory + +@Suite("DuplicateDetector Tests") +struct DuplicateDetectorTests { + + @Test("Cross-format duplicate: same vendor+name as VST3 and AU forms one group") + func crossFormatDuplicate() throws { + let inputs = [ + DuplicatePluginInput(name: "Pro-Q 3", vendorName: "FabFilter", bundleIdentifier: "com.fabfilter.proq3.vst3", format: .vst3, path: "/Library/Audio/Plug-Ins/VST3/Pro-Q 3.vst3"), + DuplicatePluginInput(name: "Pro-Q 3", vendorName: "FabFilter", bundleIdentifier: "com.fabfilter.proq3.au", format: .au, path: "/Library/Audio/Plug-Ins/Components/Pro-Q 3.component") + ] + let report = DuplicateDetector().analyze(inputs) + #expect(report.formatDuplicateCount == 1) + #expect(report.locationDuplicateCount == 0) + let group = try #require(report.formatGroups.first) + #expect(group.members.count == 2) + #expect(group.id == "fabfilter|pro-q 3") + #expect(group.displayName == "FabFilter – Pro-Q 3") + #expect(report.duplicatePaths.count == 2) + } + + @Test("Case-insensitivity: differing casing still groups together") + func caseInsensitiveGrouping() throws { + let inputs = [ + DuplicatePluginInput(name: "Pro-Q 3", vendorName: "FabFilter", bundleIdentifier: "com.fabfilter.proq3.vst3", format: .vst3, path: "/A/Pro-Q 3.vst3"), + DuplicatePluginInput(name: "pro-q 3", vendorName: "fabfilter", bundleIdentifier: "com.fabfilter.proq3.au", format: .au, path: "/B/Pro-Q 3.component") + ] + let report = DuplicateDetector().analyze(inputs) + #expect(report.formatDuplicateCount == 1) + let group = try #require(report.formatGroups.first) + #expect(group.members.count == 2) + #expect(group.id == "fabfilter|pro-q 3") + } + + @Test("Location duplicate: same bundle id and format at two paths forms one group") + func locationDuplicate() throws { + let inputs = [ + DuplicatePluginInput(name: "Serum", vendorName: "Xfer Records", bundleIdentifier: "com.xferrecords.serum", format: .vst3, path: "/Library/Audio/Plug-Ins/VST3/Serum.vst3"), + DuplicatePluginInput(name: "Serum", vendorName: "Xfer Records", bundleIdentifier: "com.xferrecords.serum", format: .vst3, path: "/Users/me/Library/Audio/Plug-Ins/VST3/Serum.vst3") + ] + let report = DuplicateDetector().analyze(inputs) + #expect(report.locationDuplicateCount == 1) + let group = try #require(report.locationGroups.first) + #expect(group.members.count == 2) + #expect(group.id == "com.xferrecords.serum|vst3") + #expect(report.formatDuplicateCount == 0) + } + + @Test("Not a duplicate: single format and single path yield no groups") + func singleInstanceIsNotDuplicate() { + let inputs = [ + DuplicatePluginInput(name: "Diva", vendorName: "u-he", bundleIdentifier: "com.u-he.diva", format: .vst3, path: "/Library/Audio/Plug-Ins/VST3/Diva.vst3") + ] + let report = DuplicateDetector().analyze(inputs) + #expect(report.formatGroups.isEmpty) + #expect(report.locationGroups.isEmpty) + #expect(report.duplicatePaths.isEmpty) + } + + @Test("Empty input produces an empty report") + func emptyInput() { + let report = DuplicateDetector().analyze([]) + #expect(report.formatGroups.isEmpty) + #expect(report.locationGroups.isEmpty) + #expect(report.duplicatePaths.isEmpty) + } + + @Test("Plugin present in three formats forms a group with three members") + func threeFormatDuplicate() throws { + let inputs = [ + DuplicatePluginInput(name: "Pro-Q 3", vendorName: "FabFilter", bundleIdentifier: "com.fabfilter.proq3.au", format: .au, path: "/Library/Audio/Plug-Ins/Components/Pro-Q 3.component"), + DuplicatePluginInput(name: "Pro-Q 3", vendorName: "FabFilter", bundleIdentifier: "com.fabfilter.proq3.vst2", format: .vst2, path: "/Library/Audio/Plug-Ins/VST/Pro-Q 3.vst"), + DuplicatePluginInput(name: "Pro-Q 3", vendorName: "FabFilter", bundleIdentifier: "com.fabfilter.proq3.vst3", format: .vst3, path: "/Library/Audio/Plug-Ins/VST3/Pro-Q 3.vst3") + ] + let report = DuplicateDetector().analyze(inputs) + #expect(report.formatDuplicateCount == 1) + let group = try #require(report.formatGroups.first) + #expect(group.members.count == 3) + #expect(group.members.map { $0.format.rawValue } == ["au", "vst2", "vst3"]) + #expect(report.duplicatePaths.count == 3) + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/InsightsDataTests.swift b/PluginUpdater/PluginventoryTests/Services/InsightsDataTests.swift new file mode 100644 index 0000000..299b68e --- /dev/null +++ b/PluginUpdater/PluginventoryTests/Services/InsightsDataTests.swift @@ -0,0 +1,113 @@ +import Testing +import Foundation +@testable import Pluginventory + +@Suite("InsightsData Tests") +struct InsightsDataTests { + + private func makePlugin( + name: String = "Plugin", + vendor: String, + format: PluginFormat, + category: PluginCategory, + fileSize: Int64 = 0 + ) -> InsightPlugin { + InsightPlugin(name: name, vendorName: vendor, format: format, category: category, fileSize: fileSize) + } + + @Test("pluginCount and totalSizeBytes sum correctly") + func countAndTotalSize() { + let plugins = [ + makePlugin(vendor: "Acme", format: .vst3, category: .synth, fileSize: 100), + makePlugin(vendor: "Beta", format: .au, category: .reverb, fileSize: 250), + makePlugin(vendor: "Gamma", format: .clap, category: .delay, fileSize: 50) + ] + let data = InsightsData.build(from: plugins) + #expect(data.pluginCount == 3) + #expect(data.totalSizeBytes == 400) + } + + @Test("manufacturerCount counts distinct vendors") + func distinctVendors() { + let plugins = [ + makePlugin(vendor: "Acme", format: .vst3, category: .synth), + makePlugin(vendor: "Acme", format: .au, category: .reverb), + makePlugin(vendor: "Beta", format: .clap, category: .delay) + ] + let data = InsightsData.build(from: plugins) + #expect(data.pluginCount == 3) + #expect(data.manufacturerCount == 2) + } + + @Test("formatCounts aggregates and excludes zero-count formats") + func formatAggregation() throws { + let plugins = [ + makePlugin(vendor: "Acme", format: .vst3, category: .synth), + makePlugin(vendor: "Beta", format: .vst3, category: .reverb), + makePlugin(vendor: "Gamma", format: .au, category: .delay) + ] + let data = InsightsData.build(from: plugins) + #expect(data.formatCounts.count == 2) + #expect(!data.formatCounts.contains { $0.format == .clap }) + let first = try #require(data.formatCounts.first) + #expect(first.format == .vst3) + #expect(first.count == 2) + } + + @Test("categoryCounts excludes empty categories and sorts by count desc") + func categoryAggregation() throws { + let plugins = [ + makePlugin(vendor: "Acme", format: .vst3, category: .reverb), + makePlugin(vendor: "Beta", format: .au, category: .reverb), + makePlugin(vendor: "Gamma", format: .clap, category: .reverb), + makePlugin(vendor: "Delta", format: .vst2, category: .synth) + ] + let data = InsightsData.build(from: plugins) + #expect(data.categoryCounts.count == 2) + let first = try #require(data.categoryCounts.first) + #expect(first.category == .reverb) + #expect(first.count == 3) + } + + @Test("manufacturersRanked sorts by count desc then name asc") + func manufacturerRankingAndTieBreak() throws { + let plugins = [ + makePlugin(vendor: "Zephyr", format: .vst3, category: .synth), + makePlugin(vendor: "Apex", format: .au, category: .reverb), + makePlugin(vendor: "Apex", format: .clap, category: .delay) + ] + let data = InsightsData.build(from: plugins) + #expect(data.manufacturersRanked.map(\.name) == ["Apex", "Zephyr"]) + } + + @Test("topManufacturers respects the limit and ordering") + func topManufacturersLimits() throws { + let plugins = [ + makePlugin(vendor: "Acme", format: .vst3, category: .synth), + makePlugin(vendor: "Acme", format: .au, category: .reverb), + makePlugin(vendor: "Acme", format: .clap, category: .delay), + makePlugin(vendor: "Beta", format: .vst3, category: .synth), + makePlugin(vendor: "Beta", format: .au, category: .reverb), + makePlugin(vendor: "Gamma", format: .vst2, category: .eq) + ] + let data = InsightsData.build(from: plugins) + let top2 = data.topManufacturers(2) + #expect(top2.count == 2) + let firstTop = try #require(top2.first) + #expect(firstTop.name == "Acme") + #expect(data.topManufacturers(0).isEmpty) + #expect(data.topManufacturers(-5).isEmpty) + #expect(data.topManufacturers(1000).count == 3) + } + + @Test("empty input yields zeroed values and empty arrays") + func emptyInput() { + let data = InsightsData.build(from: []) + #expect(data.pluginCount == 0) + #expect(data.manufacturerCount == 0) + #expect(data.totalSizeBytes == 0) + #expect(data.formatCounts.isEmpty) + #expect(data.categoryCounts.isEmpty) + #expect(data.manufacturersRanked.isEmpty) + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/OffloadManagerTests.swift b/PluginUpdater/PluginventoryTests/Services/OffloadManagerTests.swift new file mode 100644 index 0000000..ea9f112 --- /dev/null +++ b/PluginUpdater/PluginventoryTests/Services/OffloadManagerTests.swift @@ -0,0 +1,181 @@ +import Testing +import Foundation +@testable import Pluginventory + +@Suite("OffloadManager Tests") +struct OffloadManagerTests { + + // MARK: - Fixtures + + /// A fake bundle plus the temp roots that must be cleaned up afterwards. + private struct Fixture { + let manager: OffloadManager + /// The fake plugin bundle (a directory with a file inside) to offload. + let sourceBundle: URL + /// The base temp directory containing everything; remove it to clean up. + let root: URL + /// The offload destination root the manager is pointed at. + let offloadRoot: URL + } + + /// Creates a fake plugin bundle (a directory containing one file) under a fresh + /// temp source folder, alongside a fresh temp offload root and a manager pointed + /// at that root. The caller must `cleanup` the returned fixture. + private func makeFixture(bundleName: String = "TestPlugin.vst3") throws -> Fixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OffloadManagerTests-\(UUID().uuidString)") + let sourceRoot = root.appendingPathComponent("source") + let offloadRoot = root.appendingPathComponent("offload") + + let bundle = sourceRoot.appendingPathComponent(bundleName) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + + // A file inside the bundle so it has a non-zero measured size. + let payload = bundle.appendingPathComponent("payload.bin") + try Data(repeating: 0xAB, count: 4_096).write(to: payload) + + let manager = OffloadManager(offloadRoot: offloadRoot) + return Fixture(manager: manager, sourceBundle: bundle, root: root, offloadRoot: offloadRoot) + } + + private func cleanup(_ fixture: Fixture) { + try? FileManager.default.removeItem(at: fixture.root) + } + + private func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + /// Runs an async throwing operation and returns the `OffloadError` it threw, + /// or `nil` if it did not throw an `OffloadError`. + private func caughtOffloadError( + _ operation: () async throws -> Void + ) async -> OffloadManager.OffloadError? { + do { + try await operation() + return nil + } catch let error as OffloadManager.OffloadError { + return error + } catch { + return nil + } + } + + // MARK: - Offload + + @Test("offload moves the bundle into the offload root and returns a matching record") + func offloadMovesBundle() async throws { + let fixture = try makeFixture() + defer { cleanup(fixture) } + + let record = try await fixture.manager.offload(bundleAt: fixture.sourceBundle) + + // The record describes the move accurately. + #expect(record.bundleName == fixture.sourceBundle.lastPathComponent) + #expect(record.sizeBytes > 0) + #expect(record.originalPath == fixture.sourceBundle.path) + + // Source is gone; destination exists inside the offload root under the same name. + #expect(exists(fixture.sourceBundle) == false) + + let offloadedURL = URL(fileURLWithPath: record.offloadedPath) + #expect(exists(offloadedURL)) + #expect(offloadedURL.deletingLastPathComponent().path == fixture.offloadRoot.path) + #expect(offloadedURL.lastPathComponent == fixture.sourceBundle.lastPathComponent) + } + + // MARK: - Restore + + @Test("restore moves the bundle back to its original path") + func restoreMovesBundleBack() async throws { + let fixture = try makeFixture() + defer { cleanup(fixture) } + + let record = try await fixture.manager.offload(bundleAt: fixture.sourceBundle) + #expect(exists(fixture.sourceBundle) == false) + + try await fixture.manager.restore(record) + + // Back at the original location; the offloaded copy is gone. + #expect(exists(URL(fileURLWithPath: record.originalPath))) + #expect(exists(URL(fileURLWithPath: record.offloadedPath)) == false) + } + + // MARK: - Error paths + + @Test("offloading a missing source throws sourceMissing") + func offloadMissingSourceThrows() async throws { + let fixture = try makeFixture() + defer { cleanup(fixture) } + + let missing = fixture.root.appendingPathComponent("DoesNotExist.vst3") + + await #expect(throws: OffloadManager.OffloadError.self) { + _ = try await fixture.manager.offload(bundleAt: missing) + } + } + + @Test("offloading into an occupied destination throws alreadyExists") + func offloadDestinationOccupiedThrows() async throws { + let fixture = try makeFixture() + defer { cleanup(fixture) } + + // Pre-occupy the destination path inside the offload root. + let destination = fixture.offloadRoot.appendingPathComponent(fixture.sourceBundle.lastPathComponent) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + + let error = await caughtOffloadError { + _ = try await fixture.manager.offload(bundleAt: fixture.sourceBundle) + } + let offloadError = try #require(error) + guard case .alreadyExists = offloadError else { + Issue.record("Expected .alreadyExists, got \(offloadError)") + return + } + + // The guard fired before any move: the source is untouched. + #expect(exists(fixture.sourceBundle)) + } + + @Test("restore into an occupied original location throws restoreTargetOccupied") + func restoreTargetOccupiedThrows() async throws { + let fixture = try makeFixture() + defer { cleanup(fixture) } + + let record = try await fixture.manager.offload(bundleAt: fixture.sourceBundle) + + // Something now occupies the original location. + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: record.originalPath), + withIntermediateDirectories: true + ) + + let error = await caughtOffloadError { + try await fixture.manager.restore(record) + } + let offloadError = try #require(error) + guard case .restoreTargetOccupied = offloadError else { + Issue.record("Expected .restoreTargetOccupied, got \(offloadError)") + return + } + + // The offloaded copy is left in place when restore refuses. + #expect(exists(URL(fileURLWithPath: record.offloadedPath))) + } + + // MARK: - Queries + + @Test("isOffloaded is true after offload and false after restore") + func isOffloadedReflectsState() async throws { + let fixture = try makeFixture() + defer { cleanup(fixture) } + + let record = try await fixture.manager.offload(bundleAt: fixture.sourceBundle) + let afterOffload = await fixture.manager.isOffloaded(record) + #expect(afterOffload) + + try await fixture.manager.restore(record) + let afterRestore = await fixture.manager.isOffloaded(record) + #expect(afterRestore == false) + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/OffloadedPluginTests.swift b/PluginUpdater/PluginventoryTests/Services/OffloadedPluginTests.swift new file mode 100644 index 0000000..c95a7f3 --- /dev/null +++ b/PluginUpdater/PluginventoryTests/Services/OffloadedPluginTests.swift @@ -0,0 +1,52 @@ +import Testing +import Foundation +@testable import Pluginventory + +@Suite("OffloadedPlugin Tests") +struct OffloadedPluginTests { + + @Test("offloadRecord round-trips the stored fields") + func recordRoundTrip() { + let date = Date(timeIntervalSince1970: 1000) + let item = OffloadedPlugin( + name: "X", vendorName: "V", bundleIdentifier: "com.v.x", formatRaw: "vst3", + originalPath: "/Library/Audio/Plug-Ins/VST3/X.vst3", + offloadedPath: "/tmp/off/X.vst3", sizeBytes: 123, offloadedAt: date + ) + let record = item.offloadRecord + #expect(record.originalPath == "/Library/Audio/Plug-Ins/VST3/X.vst3") + #expect(record.offloadedPath == "/tmp/off/X.vst3") + #expect(record.bundleName == "X.vst3") + #expect(record.sizeBytes == 123) + #expect(record.offloadedAt == date) + #expect(item.format == .vst3) + } + + @Test("Offload then restore via manager using an OffloadedPlugin record") + func offloadRestoreIntegration() async throws { + let fm = FileManager.default + let tmp = fm.temporaryDirectory.appendingPathComponent("OffloadInt-\(UUID().uuidString)") + let installDir = tmp.appendingPathComponent("VST3") + let offloadRoot = tmp.appendingPathComponent("Offload") + try fm.createDirectory(at: installDir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: tmp) } + + let bundle = installDir.appendingPathComponent("Test.vst3") + try fm.createDirectory(at: bundle.appendingPathComponent("Contents"), withIntermediateDirectories: true) + try Data(repeating: 0, count: 500).write(to: bundle.appendingPathComponent("Contents/x.bin")) + + let manager = OffloadManager(offloadRoot: offloadRoot) + let record = try await manager.offload(bundleAt: bundle) + #expect(!fm.fileExists(atPath: bundle.path)) + #expect(fm.fileExists(atPath: record.offloadedPath)) + + let item = OffloadedPlugin( + name: "Test", vendorName: "V", bundleIdentifier: "com.v.test", formatRaw: "vst3", + originalPath: record.originalPath, offloadedPath: record.offloadedPath, + sizeBytes: record.sizeBytes, offloadedAt: record.offloadedAt + ) + try await manager.restore(item.offloadRecord) + #expect(fm.fileExists(atPath: bundle.path)) + #expect(!fm.fileExists(atPath: record.offloadedPath)) + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/PluginCategoryClassifierTests.swift b/PluginUpdater/PluginventoryTests/Services/PluginCategoryClassifierTests.swift new file mode 100644 index 0000000..be53a03 --- /dev/null +++ b/PluginUpdater/PluginventoryTests/Services/PluginCategoryClassifierTests.swift @@ -0,0 +1,181 @@ +import Testing +import Foundation +@testable import Pluginventory + +@Suite("PluginCategoryClassifier Tests") +struct PluginCategoryClassifierTests { + + // MARK: - Resolution Order + + @Test("Bundle-id prefix match wins over the name heuristic") + func bundleIDPrefixBeatsKeyword() { + let classifier = PluginCategoryClassifier(mappings: [ + CategoryMapping(match: "com.example.delaything", field: .bundleIDPrefix, category: .synth) + ]) + // Name contains "delay" (heuristic -> .delay) but the prefix mapping wins. + let result = classifier.classify( + bundleID: "com.example.delaything.v1", + vendorName: "", + name: "Delay Thing" + ) + #expect(result == .synth) + } + + @Test("Exact vendor match is used when no prefix matches") + func vendorMatch() { + let classifier = PluginCategoryClassifier(mappings: [ + CategoryMapping(match: "Valhalla DSP", field: .vendor, category: .reverb) + ]) + // Vendor comparison is case-insensitive. + let result = classifier.classify( + bundleID: "com.unknown.thing", + vendorName: "valhalla dsp", + name: "Mystery" + ) + #expect(result == .reverb) + } + + @Test("Vendor match takes precedence over name keyword and heuristic") + func vendorBeatsNameKeyword() { + let classifier = PluginCategoryClassifier(mappings: [ + CategoryMapping(match: "Acme", field: .vendor, category: .utility), + CategoryMapping(match: "reverb", field: .nameKeyword, category: .creative) + ]) + let result = classifier.classify(bundleID: "com.x.y", vendorName: "Acme", name: "Spring Reverb") + #expect(result == .utility) + } + + @Test("Name keyword match is used when no prefix or vendor matches") + func nameKeywordMatch() { + let classifier = PluginCategoryClassifier(mappings: [ + CategoryMapping(match: "vocoder", field: .nameKeyword, category: .creative) + ]) + let result = classifier.classify( + bundleID: "com.acme.x", + vendorName: "Acme", + name: "Robo Vocoder 3000" + ) + #expect(result == .creative) + } + + @Test("Name keyword mapping takes precedence over the built-in heuristic") + func nameKeywordBeatsHeuristic() { + // Deliberately map the "reverb" keyword to .creative; the heuristic would say .reverb. + let classifier = PluginCategoryClassifier(mappings: [ + CategoryMapping(match: "reverb", field: .nameKeyword, category: .creative) + ]) + let result = classifier.classify(bundleID: "", vendorName: "", name: "Spring Reverb") + #expect(result == .creative) + } + + // MARK: - Longest-Prefix Precedence + + @Test("Longest matching bundle-id prefix wins over a shorter one") + func longestPrefixWins() { + // Generic prefix listed first, specific one second — longest must still win. + let classifier = PluginCategoryClassifier(mappings: [ + CategoryMapping(match: "com.valhalladsp.", field: .bundleIDPrefix, category: .reverb), + CategoryMapping(match: "com.valhalladsp.valhalladelay", field: .bundleIDPrefix, category: .delay) + ]) + let delay = classifier.classify(bundleID: "com.ValhallaDSP.ValhallaDelay", vendorName: "", name: "") + let room = classifier.classify(bundleID: "com.ValhallaDSP.ValhallaRoom", vendorName: "", name: "") + #expect(delay == .delay) + #expect(room == .reverb) + } + + // MARK: - Built-in Heuristic + + @Test("Built-in heuristic classifies by keyword when mappings are empty") + func heuristicFallback() { + let classifier = PluginCategoryClassifier(mappings: []) + #expect(classifier.classify(bundleID: "x", vendorName: "y", name: "Big Reverb") == .reverb) + #expect(classifier.classify(bundleID: "x", vendorName: "y", name: "Vintage Compressor") == .compressor) + #expect(classifier.classify(bundleID: "x", vendorName: "y", name: "Tape Machine") == .saturation) + #expect(classifier.classify(bundleID: "x", vendorName: "y", name: "Sub Bass Synth") == .synth) + #expect(classifier.classify(bundleID: "x", vendorName: "y", name: "Loudness Meter") == .metering) + } + + @Test("Heuristic keyword order resolves overlapping names deterministically") + func heuristicOrdering() { + let classifier = PluginCategoryClassifier(mappings: []) + // "delay" is checked before "tape", so a tape-style delay resolves to .delay. + #expect(classifier.classify(bundleID: "x", vendorName: "y", name: "Tape Delay") == .delay) + } + + // MARK: - Fallback + + @Test("Unknown plugin falls back to uncategorized") + func unknownIsUncategorized() { + let classifier = PluginCategoryClassifier(mappings: []) + let result = classifier.classify(bundleID: "com.x.y", vendorName: "Nobody", name: "Widget") + #expect(result == .uncategorized) + } + + // MARK: - Default Mappings + + @Test("Default mappings are populated") + func defaultMappingsNonEmpty() { + #expect(!PluginCategoryClassifier.defaultMappings.isEmpty) + } + + @Test("Default mappings classify well-known plugins") + func defaultMappingsClassify() { + let classifier = PluginCategoryClassifier(mappings: PluginCategoryClassifier.defaultMappings) + #expect(classifier.classify(bundleID: "com.fabfilter.Pro-C 2", vendorName: "FabFilter", name: "Pro-C 2") == .compressor) + #expect(classifier.classify(bundleID: "com.izotope.RX 10", vendorName: "iZotope", name: "RX") == .noiseReduction) + #expect(classifier.classify(bundleID: "com.example.unknown", vendorName: "Klanghelm", name: "MJUC") == .compressor) + // Bundle-id comparison is case-insensitive. + #expect(classifier.classify(bundleID: "COM.FABFILTER.PRO-Q", vendorName: "", name: "") == .eq) + } + + @Test("Default mappings honor longest-prefix precedence for Valhalla") + func defaultMappingsLongestPrefix() { + let classifier = PluginCategoryClassifier(mappings: PluginCategoryClassifier.defaultMappings) + #expect(classifier.classify(bundleID: "com.ValhallaDSP.ValhallaDelay", vendorName: "", name: "") == .delay) + #expect(classifier.classify(bundleID: "com.ValhallaDSP.ValhallaRoom", vendorName: "", name: "") == .reverb) + } + + // MARK: - CategoryMapping Codable + + @Test("CategoryMapping decodes from the bundled JSON shape") + func categoryMappingDecodes() throws { + let json = """ + [{"match":"com.fabfilter.Pro-Q","field":"bundleIDPrefix","category":"eq"}] + """ + let data = try #require(json.data(using: .utf8)) + let decoded = try JSONDecoder().decode([CategoryMapping].self, from: data) + let first = try #require(decoded.first) + #expect(first.match == "com.fabfilter.Pro-Q") + #expect(first.field == .bundleIDPrefix) + #expect(first.category == .eq) + } + + // MARK: - PluginCategory + + @Test("Display names are human-readable") + func displayNames() { + #expect(PluginCategory.eq.displayName == "EQ") + #expect(PluginCategory.channelStrip.displayName == "Channel Strip") + #expect(PluginCategory.guitarAmp.displayName == "Guitar/Amp") + #expect(PluginCategory.noiseReduction.displayName == "Noise Reduction") + #expect(PluginCategory.uncategorized.displayName == "Uncategorized") + #expect(PluginCategory.reverb.displayName == "Reverb") + } + + @Test("Identifier equals raw value for every case") + func identifierMatchesRawValue() { + for category in PluginCategory.allCases { + #expect(category.id == category.rawValue) + } + #expect(PluginCategory.allCases.contains(.uncategorized)) + } + + @Test("PluginCategory round-trips through Codable") + func codableRoundTrip() throws { + for category in PluginCategory.allCases { + let data = try JSONEncoder().encode(category) + let decoded = try JSONDecoder().decode(PluginCategory.self, from: data) + #expect(decoded == category) + } + } +} diff --git a/PluginUpdater/PluginventoryTests/Services/PluginReconcilerTests.swift b/PluginUpdater/PluginventoryTests/Services/PluginReconcilerTests.swift index 83ee3ce..6c2c22e 100644 --- a/PluginUpdater/PluginventoryTests/Services/PluginReconcilerTests.swift +++ b/PluginUpdater/PluginventoryTests/Services/PluginReconcilerTests.swift @@ -37,6 +37,22 @@ struct PluginReconcilerTests { ) } + @Test("Assigns a category to scanned plugins") + func assignsCategory() async throws { + let container = try makeContainer() + let reconciler = PluginReconciler(modelContainer: container) + let meta = makeMetadata(name: "Reverb Deluxe", bundleID: "com.test.reverbdeluxe", vendor: "TestVendor") + _ = try await reconciler.reconcile(scannedPlugins: [meta]) + + let context = ModelContext(container) + let plugins = try context.fetch(FetchDescriptor()) + let plugin = try #require(plugins.first { $0.bundleIdentifier == "com.test.reverbdeluxe" }) + let expected = PluginCategoryClassifier.loadFromBundle().classify( + bundleID: meta.bundleIdentifier, vendorName: meta.vendorName, name: meta.name + ) + #expect(plugin.category == expected) + } + @Test("Detects new plugins and creates version history") func detectsNewPlugins() async throws { let container = try makeContainer()