From 1778a6d1df5e7177ac5c214e27b2297ab0cd7cb8 Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 14:26:08 -0700 Subject: [PATCH 1/7] feat(catalog,offload): add category classifier + offload manager backends Two self-contained building blocks for the Plugoff-style features, each with tests (345 tests pass total): - PluginCategory + PluginCategoryClassifier: classifies plugins into functional categories (Synth, EQ, Dynamics, Reverb, ...) via a bundled plugin_categories.json (95 vendor/prefix rules) plus a name-keyword heuristic fallback. Resolution: longest bundle-id prefix > exact vendor > name keyword > heuristic > uncategorized. - OffloadManager (actor) + OffloadRecord: moves plugin bundles to an offload folder to reclaim disk and restores them, with collision-safe path handling and size measured before the move. UI/model integration follows in subsequent commits. Co-Authored-By: Claude Opus 4.8 --- .../Pluginventory/Models/PluginCategory.swift | 57 +++ .../Resources/plugin_categories.json | 477 ++++++++++++++++++ .../Catalog/PluginCategoryClassifier.swift | 258 ++++++++++ .../Services/Offload/OffloadManager.swift | 182 +++++++ .../Services/OffloadManagerTests.swift | 181 +++++++ .../PluginCategoryClassifierTests.swift | 181 +++++++ 6 files changed, 1336 insertions(+) create mode 100644 PluginUpdater/Pluginventory/Models/PluginCategory.swift create mode 100644 PluginUpdater/Pluginventory/Resources/plugin_categories.json create mode 100644 PluginUpdater/Pluginventory/Services/Catalog/PluginCategoryClassifier.swift create mode 100644 PluginUpdater/Pluginventory/Services/Offload/OffloadManager.swift create mode 100644 PluginUpdater/PluginventoryTests/Services/OffloadManagerTests.swift create mode 100644 PluginUpdater/PluginventoryTests/Services/PluginCategoryClassifierTests.swift 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/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/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/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) + } + } +} From 429b5346ac5706cb28993a5db608395573932eb9 Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 14:45:13 -0700 Subject: [PATCH 2/7] feat(analysis): duplicate detection, Rosetta/macOS 28 compat, insights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, tested logic layers for the competitor-parity features: - DuplicateDetector: cross-format duplicates (same plugin in VST3/AU/…) and location duplicates (same bundle+format in system & user folders), mirroring Plugoff's duplicate filter. - ArchitectureCompatibility + RosettaSunsetSummary: flags Intel-only / legacy plugins that stop working when macOS 28 removes Rosetta, for the PluginHub-style warning banner. - InsightsData: aggregates counts by format, category, and manufacturer plus totals, backing a Plugoff-style Insights view. All pure value types, Sendable, deterministic. 21 new tests; suite now 366. Co-Authored-By: Claude Opus 4.8 --- .../Analysis/ArchitectureCompatibility.swift | 46 ++++++ .../Services/Analysis/DuplicateDetector.swift | 137 ++++++++++++++++++ .../Services/Analysis/InsightsData.swift | 96 ++++++++++++ .../ArchitectureCompatibilityTests.swift | 71 +++++++++ .../Services/DuplicateDetectorTests.swift | 84 +++++++++++ .../Services/InsightsDataTests.swift | 113 +++++++++++++++ 6 files changed, 547 insertions(+) create mode 100644 PluginUpdater/Pluginventory/Services/Analysis/ArchitectureCompatibility.swift create mode 100644 PluginUpdater/Pluginventory/Services/Analysis/DuplicateDetector.swift create mode 100644 PluginUpdater/Pluginventory/Services/Analysis/InsightsData.swift create mode 100644 PluginUpdater/PluginventoryTests/Services/ArchitectureCompatibilityTests.swift create mode 100644 PluginUpdater/PluginventoryTests/Services/DuplicateDetectorTests.swift create mode 100644 PluginUpdater/PluginventoryTests/Services/InsightsDataTests.swift 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/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) + } +} From 09d6c2da53aaf44e33907e2d66e59b705e093a3f Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 14:48:54 -0700 Subject: [PATCH 3/7] feat(catalog): assign functional category to plugins during scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin gains a `category` (persisted via categoryRaw, default uncategorized). PluginReconciler runs PluginCategoryClassifier on insert and update so every scanned plugin is categorized (Synth, EQ, Reverb, …). Adds a reconciler test. Co-Authored-By: Claude Opus 4.8 --- PluginUpdater/Pluginventory/Models/Plugin.swift | 8 ++++++++ .../Services/Persistence/PluginReconciler.swift | 12 ++++++++++++ .../Services/PluginReconcilerTests.swift | 16 ++++++++++++++++ 3 files changed, 36 insertions(+) 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/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/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() From 99b9bb9e2fb34dae606a45f882a6172ce5ad7324 Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 14:53:38 -0700 Subject: [PATCH 4/7] feat(ui): category & duplicate sidebar filters + macOS 28 Rosetta banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds visible competitor-parity features to the dashboard: - Categories sidebar section — filter the library by functional category (Synth, EQ, Reverb, …), with per-category counts (Plugoff-style). - Cleanup > Duplicates filter — surfaces plugins installed in multiple formats or in both system and user folders (Plugoff-style). - Compatibility > "Won't run in macOS 28" filter and a prominent red warning banner counting Intel-only / legacy plugins that break when Apple removes Rosetta (PluginHub-style). All computed from the tested DuplicateDetector / ArchitectureCompatibility layers. Suite: 367 tests pass. Co-Authored-By: Claude Opus 4.8 --- .../Components/RosettaWarningBanner.swift | 33 +++++++ .../Views/Dashboard/DashboardView.swift | 95 +++++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 PluginUpdater/Pluginventory/Views/Components/RosettaWarningBanner.swift 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..5c95f49 100644 --- a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift +++ b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift @@ -11,6 +11,9 @@ enum SidebarFilter: Hashable { case projectsMissingPlugins case usedPlugins case unusedPlugins + case category(PluginCategory) + case duplicates + case rosettaRisk } /// Wraps a Plugin with its computed update status so the Table can sort all columns. @@ -55,8 +58,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 @@ -123,7 +130,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 +138,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 +176,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,6 +202,12 @@ struct DashboardView: View { case .unusedPlugins: let usedNames = collectUsedPluginNames() result = result.filter { !usedNames.contains($0.name.lowercased()) } + 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: break } @@ -326,14 +346,48 @@ 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) + } + + 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" + } + } + // 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,6 +404,28 @@ 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) @@ -392,7 +468,7 @@ struct DashboardView: View { projects: abletonProjects.filter { $0.missingPluginCount > 0 } ) default: - pluginTableDetail(rows: rows, manifest: manifest) + pluginTableDetail(rows: rows, manifest: manifest, rosettaRiskCount: counts.rosettaRisk) } } .frame(minWidth: 700, minHeight: 400) @@ -457,7 +533,7 @@ 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 { Table(rows, selection: $selectedPluginIDs, sortOrder: $sortOrder) { Group { TableColumn("Name", value: \PluginRow.name) { (row: PluginRow) in @@ -528,6 +604,13 @@ struct DashboardView: View { } .background(NSTableViewFinder.enableColumnAutoResize()) .id(sidebarSelection) + .safeAreaInset(edge: .top) { + if rosettaRiskCount > 0 && sidebarSelection != .rosettaRisk { + RosettaWarningBanner(count: rosettaRiskCount) { + sidebarSelection = .rosettaRisk + } + } + } .contextMenu(forSelectionType: PersistentIdentifier.self) { ids in if !ids.isEmpty { let count = ids.count From ab1e67d7b94acdbb2107fecd33b09a22c4d94218 Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 14:56:06 -0700 Subject: [PATCH 5/7] =?UTF-8?q?feat(ui):=20Insights=20view=20=E2=80=94=20f?= =?UTF-8?q?ormat/category/manufacturer=20breakdowns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Plugoff-style Insights sheet (toolbar chart button) built on the tested InsightsData layer: overview tiles (plugins, manufacturers, total size) plus Swift Charts bar breakdowns by format, category, and top manufacturers, over currently-visible plugins. Co-Authored-By: Claude Opus 4.8 --- .../Views/Dashboard/DashboardView.swift | 26 ++++ .../Views/Dashboard/InsightsView.swift | 120 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 PluginUpdater/Pluginventory/Views/Dashboard/InsightsView.swift diff --git a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift index 5c95f49..5d3929b 100644 --- a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift +++ b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift @@ -81,6 +81,7 @@ struct DashboardView: View { @State private var selectedPluginIDs: Set = [] @State private var showInspector = false @State private var selectedProjectForDetail: AbletonProject? + @State private var showInsights = false /// Maps persisted column name strings to their KeyPathComparator. private static let pluginColumnMap: [String: PartialKeyPath] = [ @@ -360,6 +361,20 @@ struct DashboardView: View { 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" @@ -693,6 +708,12 @@ struct DashboardView: View { 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: { @@ -702,6 +723,11 @@ struct DashboardView: View { } } } + .sheet(isPresented: $showInsights) { + NavigationStack { + InsightsView(data: buildInsightsData()) + } + } .inspector(isPresented: $showInspector) { if let plugin = selectedPlugin { PluginDetailView(plugin: plugin, manifest: appState.manifestEntries) 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)) + } +} From 5b98313f79fb4b234ef66a607e4134d190e88a31 Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 15:03:28 -0700 Subject: [PATCH 6/7] feat(offload): move unused plugins to reclaim disk, with restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the tested OffloadManager into the app (Plugoff-style offloading): - New OffloadedPlugin @Model persists what was moved + where from. - AppState.offloadPlugins moves bundles to ~/PluginventoryOffload and drops them from the active list; restoreOffloaded moves them back and rescans. Failures are surfaced, never silent; nothing is clobbered. - Dashboard: right-click "Offload to Reclaim Space" (with a confirmation), a "Storage ▸ Offloaded (N) · X saved" sidebar entry, and a restore list. Adds an offload↔restore round-trip integration test. Suite: 369 tests. Co-Authored-By: Claude Opus 4.8 --- .../Pluginventory/App/AppState.swift | 61 +++++++++++++++ .../Models/OffloadedPlugin.swift | 54 +++++++++++++ .../Persistence/PersistenceController.swift | 1 + .../Views/Dashboard/DashboardView.swift | 77 ++++++++++++++++++- .../Services/OffloadedPluginTests.swift | 52 +++++++++++++ 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 PluginUpdater/Pluginventory/Models/OffloadedPlugin.swift create mode 100644 PluginUpdater/PluginventoryTests/Services/OffloadedPluginTests.swift 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/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/Views/Dashboard/DashboardView.swift b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift index 5d3929b..7604d63 100644 --- a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift +++ b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift @@ -14,6 +14,7 @@ enum SidebarFilter: Hashable { case category(PluginCategory) case duplicates case rosettaRisk + case offloaded } /// Wraps a Plugin with its computed update status so the Table can sort all columns. @@ -73,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 = "" @@ -82,6 +84,7 @@ struct DashboardView: View { @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] = [ @@ -209,7 +212,7 @@ struct DashboardView: View { result = result.filter { duplicateReport.duplicatePaths.contains($0.path) } case .rosettaRisk: result = result.filter { ArchitectureCompatibility.breaksInMacOS28($0.architectures) } - case .allProjects, .projectsMissingPlugins: + case .allProjects, .projectsMissingPlugins, .offloaded: break } } @@ -394,6 +397,47 @@ struct DashboardView: View { } } + 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 { @@ -445,6 +489,12 @@ struct DashboardView: View { 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") @@ -482,6 +532,8 @@ struct DashboardView: View { projectListDetail( projects: abletonProjects.filter { $0.missingPluginCount > 0 } ) + case .offloaded: + offloadedListDetail() default: pluginTableDetail(rows: rows, manifest: manifest, rosettaRiskCount: counts.rosettaRisk) } @@ -647,6 +699,12 @@ struct DashboardView: View { Divider() + Button("Offload to Reclaim Space") { + pendingOffloadIDs = ids + } + + Divider() + if sidebarSelection == .hidden { Button("Unhide\(count > 1 ? " \(count) Plugins" : " Plugin")") { setHidden(false, for: ids) @@ -723,6 +781,23 @@ struct DashboardView: View { } } } + .confirmationDialog( + offloadDialogTitle, + isPresented: Binding( + get: { pendingOffloadIDs != nil }, + set: { if !$0 { pendingOffloadIDs = nil } } + ), + presenting: pendingOffloadIDs + ) { ids in + Button("Offload", role: .destructive) { + let toOffload = plugins(for: ids) + pendingOffloadIDs = nil + Task { await appState.offloadPlugins(toOffload) } + } + Button("Cancel", role: .cancel) { pendingOffloadIDs = nil } + } 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()) 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)) + } +} From 90bd15ce4155863a582f8eadca82b0acb34d4ef1 Mon Sep 17 00:00:00 2001 From: Tomio Ueda Date: Wed, 19 Aug 2026 23:25:36 -0700 Subject: [PATCH 7/7] fix(ui): split pluginTableDetail into subviews to fix CI type-check timeout The dashboard table was one ~230-line expression (Table + 12 modifiers). Xcode 26.6 type-checks it fine, but the CI runner's older Xcode hit "unable to type-check this expression in reasonable time". Extracted the table, context menu, overlay, banner, footer, toolbar, dialog actions, inspector, and search handler into small helpers so no single expression is large. No behavior change. --- .../Views/Dashboard/DashboardView.swift | 318 ++++++++++-------- 1 file changed, 180 insertions(+), 138 deletions(-) diff --git a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift index 7604d63..b301f46 100644 --- a/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift +++ b/PluginUpdater/Pluginventory/Views/Dashboard/DashboardView.swift @@ -601,6 +601,47 @@ struct DashboardView: View { @ViewBuilder 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 @@ -669,166 +710,167 @@ struct DashboardView: View { .width(min: 50, ideal: 70, max: 90) } } - .background(NSTableViewFinder.enableColumnAutoResize()) - .id(sidebarSelection) - .safeAreaInset(edge: .top) { - if rosettaRiskCount > 0 && sidebarSelection != .rosettaRisk { - RosettaWarningBanner(count: rosettaRiskCount) { - sidebarSelection = .rosettaRisk - } - } - } .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() - - Button("Reveal in Finder") { - revealInFinder(ids: ids) - } - Button("Open Publisher Website") { - openVendorWebsites(for: ids, manifest: manifest) - } - - Divider() + rowContextMenu(ids, manifest: manifest) + } + .overlay { + tableEmptyOverlay(rows: rows) + } + } - Button("Offload to Reclaim Space") { - pendingOffloadIDs = ids + @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) - } - } - } - 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") + .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") } - .labelStyle(.titleAndIcon) + .padding(.horizontal, 12) + .padding(.vertical, 6) } } } - .confirmationDialog( - offloadDialogTitle, - isPresented: Binding( - get: { pendingOffloadIDs != nil }, - set: { if !$0 { pendingOffloadIDs = nil } } - ), - presenting: pendingOffloadIDs - ) { ids in - Button("Offload", role: .destructive) { - let toOffload = plugins(for: ids) - pendingOffloadIDs = nil - Task { await appState.offloadPlugins(toOffload) } + 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) } - Button("Cancel", role: .cancel) { pendingOffloadIDs = nil } - } 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()) - } + } + + @ViewBuilder + private func offloadDialogActions(_ ids: Set) -> some View { + Button("Offload", role: .destructive) { + let toOffload = plugins(for: ids) + pendingOffloadIDs = nil + Task { await appState.offloadPlugins(toOffload) } } - .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) - } + 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: 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 - } + } + + 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 } } } - .onChange(of: selectedPluginIDs) { _, newValue in - if !newValue.isEmpty { - showInspector = true - } - } } }