Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions PluginUpdater/Pluginventory/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ final class AppState {
private let vendorURLResolver = VendorURLResolver()
private let appUpdateChecker = AppUpdateChecker()
private let manifestCacheManager = ManifestCacheManager()
private let bundleSizeService = BundleSizeService()
private var prefetchTask: Task<Void, Never>?
private var sizeComputationTask: Task<Void, Never>?

/// Plist fields from most recent scan, keyed by bundleID.
/// Used by VendorURLResolver for URL extraction from plist metadata.
Expand Down Expand Up @@ -268,6 +270,9 @@ final class AppState {
NotificationManager.shared.notifyChanges(result.changes)
}

// Resolve bundle sizes in the background (off the scan critical path)
startSizeComputation()

// Check for available updates + resolve vendor URLs
await checkForUpdates()

Expand Down Expand Up @@ -307,6 +312,9 @@ final class AppState {

applyResult(result, errors: scanResult.errors)

// Refresh sizes for any changed bundles (mtime cache skips the rest)
startSizeComputation()

// Notify for incremental changes
NotificationManager.shared.notifyChanges(result.changes)
} catch {
Expand Down Expand Up @@ -466,6 +474,62 @@ final class AppState {
}
}

// MARK: - Bundle Sizes

/// Starts (or restarts) the background bundle-size pass.
///
/// Size is computed off the scan critical path so the plugin list appears
/// immediately after a scan. Results are cached per bundle modification date,
/// so this is near-instant on rescans where nothing changed.
func startSizeComputation() {
sizeComputationTask?.cancel()
sizeComputationTask = Task { [weak self] in
await self?.computePluginSizes()
}
}

/// Cancels any running background size pass.
func cancelSizeComputation() {
sizeComputationTask?.cancel()
sizeComputationTask = nil
}

/// Computes bundle sizes for all present plugins and writes them back,
/// skipping bundles whose modification date matches the cached value.
private func computePluginSizes() async {
let context = modelContainer.mainContext
let descriptor = FetchDescriptor<Plugin>(
predicate: #Predicate { !$0.isRemoved }
)
guard let plugins = try? context.fetch(descriptor), !plugins.isEmpty else { return }

let requests = plugins.map { plugin in
BundleSizeService.SizeRequest(
path: plugin.path,
cachedMtime: plugin.sizeCacheMtime,
cachedSize: plugin.fileSize
)
}

let results = await bundleSizeService.computeSizes(requests)
if Task.isCancelled { return }

var changed = false
for plugin in plugins {
guard let result = results[plugin.path] else { continue }
if plugin.fileSize != result.size || plugin.sizeCacheMtime != result.mtime {
plugin.fileSize = result.size
plugin.sizeCacheMtime = result.mtime
changed = true
}
}

if changed {
try? context.save()
AppLogger.shared.info("Bundle sizes updated for \(plugins.count) plugins", category: "scan")
}
}

// MARK: - Project Scanning

func performProjectScan() async {
Expand Down
3 changes: 3 additions & 0 deletions PluginUpdater/Pluginventory/Models/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ final class Plugin {
/// Comma-separated architecture raw values (e.g. "arm64,x86_64").
var architecturesRaw: String = ""
var fileSize: Int64 = 0
/// Bundle modification date `fileSize` was last computed for.
/// Lets `BundleSizeService` skip re-walking unchanged bundles across scans.
var sizeCacheMtime: Date?
var fileCreationDate: Date?

@Relationship(deleteRule: .cascade, inverse: \PluginVersion.plugin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ actor PluginReconciler {
existing.path = metadata.url.path
existing.name = metadata.name
existing.architectures = metadata.architectures
existing.fileSize = metadata.fileSize
// Preserve the background-computed size when the scan didn't produce one
// (the scan no longer walks bundle contents — see BundleSizeService).
if let size = metadata.fileSize {
existing.fileSize = size
}
existing.fileCreationDate = metadata.fileCreationDate

// Plugin reappeared after being removed
Expand Down Expand Up @@ -196,7 +200,9 @@ actor PluginReconciler {
)

plugin.architectures = metadata.architectures
plugin.fileSize = metadata.fileSize
if let size = metadata.fileSize {
plugin.fileSize = size
}
plugin.fileCreationDate = metadata.fileCreationDate

// Initial version record
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ struct PluginMetadata: Sendable {
let plistFields: [String: String]

let architectures: [CPUArchitecture]
let fileSize: Int64
/// Total bundle size in bytes. `nil` when not computed during the scan —
/// size is resolved lazily off the critical path by `BundleSizeService`.
let fileSize: Int64?
let fileCreationDate: Date?
}

Expand Down Expand Up @@ -72,10 +74,13 @@ enum BundleMetadataExtractor {
let executableName = plist["CFBundleExecutable"] as? String
let architectures = ArchitectureDetector.detect(bundleURL: bundleURL, executableName: executableName)

// Bundle size: sum all files in the bundle
let fileSize = Self.calculateBundleSize(bundleURL)
// NOTE: Bundle size is deliberately NOT computed here. Recursively summing
// every file inside every bundle was the dominant scan cost (audio plugins
// ship huge sample/preset trees). Size is resolved lazily, cached by bundle
// modification date, via `BundleSizeService` after the scan completes.
let fileSize: Int64? = nil

// File creation date
// File creation date (single stat — cheap)
let fileCreationDate = Self.getCreationDate(bundleURL)

return PluginMetadata(
Expand Down Expand Up @@ -152,25 +157,6 @@ enum BundleMetadataExtractor {
return String(parts[1])
}

/// Calculates the total size of all files in a bundle directory.
private static func calculateBundleSize(_ bundleURL: URL) -> Int64 {
let fm = FileManager.default
guard let enumerator = fm.enumerator(
at: bundleURL,
includingPropertiesForKeys: [.fileSizeKey],
options: [.skipsHiddenFiles]
) else { return 0 }

var total: Int64 = 0
for case let fileURL as URL in enumerator {
if let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
let size = values.fileSize {
total += Int64(size)
}
}
return total
}

/// Gets the filesystem creation date of a bundle.
private static func getCreationDate(_ bundleURL: URL) -> Date? {
try? bundleURL.resourceValues(forKeys: [.creationDateKey]).creationDate
Expand Down
124 changes: 124 additions & 0 deletions PluginUpdater/Pluginventory/Services/Scanner/BundleSizeService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation

/// Computes plugin bundle sizes off the scan critical path.
///
/// Summing every file inside every bundle is expensive — audio plugins ship large
/// sample, preset, and wavetable trees, so a full library can be tens of gigabytes
/// across hundreds of thousands of files. Doing that during the scan was the single
/// biggest source of slow scans. This service moves that work into a background pass
/// and caches each result against the bundle's modification date, so a rescan only
/// re-walks bundles that actually changed.
actor BundleSizeService {

/// A request to (re)compute the size of a single bundle.
struct SizeRequest: Sendable {
/// Filesystem path of the bundle. Also used as the result key.
let path: String
/// Modification date the cached size was computed for, if any.
let cachedMtime: Date?
/// Previously computed size in bytes (0 if never computed).
let cachedSize: Int64
}

/// The resolved size of a bundle plus the modification date it was computed for.
struct SizeResult: Sendable {
let size: Int64
let mtime: Date?
}

private let concurrency: Int

init(concurrency: Int = Constants.Defaults.scanConcurrency) {
self.concurrency = concurrency
}

// MARK: - Public

/// Resolves sizes for the given requests, keyed by `path`.
///
/// For each request the bundle's current modification date is read (one cheap stat).
/// If it matches `cachedMtime` and a cached size exists, the cached size is returned
/// without walking the bundle. Otherwise the bundle is walked to compute a fresh size.
/// Runs with bounded concurrency to avoid saturating the filesystem.
func computeSizes(_ requests: [SizeRequest]) async -> [String: SizeResult] {
guard !requests.isEmpty else { return [:] }

var results: [String: SizeResult] = [:]
results.reserveCapacity(requests.count)

await withTaskGroup(of: (String, SizeResult).self) { group in
var inFlight = 0

for request in requests {
if inFlight >= concurrency {
if let (path, result) = await group.next() {
results[path] = result
inFlight -= 1
}
}

group.addTask {
(request.path, Self.resolve(request))
}
inFlight += 1
}

for await (path, result) in group {
results[path] = result
}
}

return results
}

// MARK: - Private

/// Resolves a single request, honoring the modification-date cache.
private static func resolve(_ request: SizeRequest) -> SizeResult {
let url = URL(fileURLWithPath: request.path)
let currentMtime = modificationDate(of: url)

// Cache hit: unchanged bundle with a previously computed size.
if let cached = request.cachedMtime,
let current = currentMtime,
cached == current,
request.cachedSize > 0 {
return SizeResult(size: request.cachedSize, mtime: current)
}

let size = directorySize(at: url)
return SizeResult(size: size, mtime: currentMtime)
}

/// The bundle's content modification date, or `nil` if unavailable.
static func modificationDate(of url: URL) -> Date? {
try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
}

/// Recursively sums the size of every regular file in a directory tree.
///
/// Uses the values prefetched by the enumerator rather than re-stat'ing each file,
/// and prefers total-allocated size (what the file actually occupies on disk),
/// falling back to logical file size.
static func directorySize(at url: URL) -> Int64 {
let keys: [URLResourceKey] = [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
let fm = FileManager.default
guard let enumerator = fm.enumerator(
at: url,
includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles]
) else { return 0 }

var total: Int64 = 0
for case let fileURL as URL in enumerator {
guard let values = try? fileURL.resourceValues(forKeys: Set(keys)) else { continue }
if values.isRegularFile == false { continue }
if let allocated = values.totalFileAllocatedSize {
total += Int64(allocated)
} else if let logical = values.fileSize {
total += Int64(logical)
}
}
return total
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ struct BundleMetadataExtractorTests {
#expect(metadata.vendorName == "FabFilter")
}

@Test("Does not compute bundle size during extraction")
func doesNotComputeSizeDuringScan() throws {
// Size is resolved lazily by BundleSizeService, not during the scan,
// so extraction must leave it nil (the dominant scan cost is removed).
let plist: [String: Any] = [
"CFBundleIdentifier": "com.test.plugin",
"CFBundleName": "Test",
"CFBundleShortVersionString": "1.0.0"
]

let bundleURL = try createMockBundle(name: "Test", extension: "vst3", plist: plist)
defer { cleanup(bundleURL) }

let metadata = try BundleMetadataExtractor.extract(from: bundleURL)
#expect(metadata.fileSize == nil)
}

@Test("Extracts metadata from CLAP bundle")
func extractsCLAPMetadata() throws {
let plist: [String: Any] = [
Expand Down
Loading
Loading