Skip to content
Open
61 changes: 61 additions & 0 deletions PluginUpdater/Pluginventory/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var sizeComputationTask: Task<Void, Never>?

Expand Down Expand Up @@ -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<Plugin>(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 {
Expand Down
54 changes: 54 additions & 0 deletions PluginUpdater/Pluginventory/Models/OffloadedPlugin.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
8 changes: 8 additions & 0 deletions PluginUpdater/Pluginventory/Models/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions PluginUpdater/Pluginventory/Models/PluginCategory.swift
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 19 in PluginUpdater/Pluginventory/Models/PluginCategory.swift

View workflow job for this annotation

GitHub Actions / build-and-test

Declaration mastering contains the term "master" which is not considered inclusive (inclusive_language)
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"
}
}
}
Loading
Loading