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
28 changes: 28 additions & 0 deletions PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions PluginUpdater/PluginUpdater/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ final class AppState {
var manifestEntries: [String: UpdateManifestEntry] = [:]
var updatesAvailableCount = 0
var availableAppUpdate: AppUpdateChecker.AppUpdate?
var isProjectScanning = false
var projectScanProgress: Double = 0
var projectScanStatusText: String = ""
var totalProjectCount = 0
var projectsWithMissingPlugins = 0

private(set) var modelContainer: ModelContainer
private var fileMonitor: FileSystemMonitor?
private var projectFileMonitor: FileSystemMonitor?
private var autoScanTimer: Timer?
private var isScanInProgress = false
private let manifestManager = ManifestManager()
Expand Down Expand Up @@ -435,6 +441,139 @@ final class AppState {
}
}

// MARK: - Project Scanning

func performProjectScan() async {
guard !isProjectScanning else { return }
isProjectScanning = true
projectScanProgress = 0
projectScanStatusText = "Discovering projects..."

// Set verbose logging from UserDefaults
AppLogger.shared.verbose = UserDefaults.standard.bool(forKey: Constants.UserDefaultsKeys.debugVerboseLogging)

do {
let directories = projectScanDirectories()
guard !directories.isEmpty else {
isProjectScanning = false
projectScanStatusText = ""
return
}

let scanStart = Date()

AppLogger.shared.info(
"Project scan started — \(directories.count) directories",
category: "scan"
)

let scanner = AbletonProjectScanner()
let reconciler = ProjectReconciler(modelContainer: modelContainer)
let stream = await scanner.scanStreaming(directories: directories)
var allScannedPaths: Set<String> = []
var errorCount = 0

for await event in stream {
switch event {
case .progress(let progress):
switch progress {
case .discovering(let dir):
projectScanStatusText = "Scanning \(dir)..."
projectScanProgress = 0.1
case .parsing(let current, let total, let name):
projectScanStatusText = "Parsing \(name) (\(current)/\(total))"
projectScanProgress = 0.1 + 0.7 * Double(current) / Double(max(total, 1))
}

case .batch(let projects):
allScannedPaths.formUnion(projects.map(\.filePath))
_ = try await reconciler.reconcile(parsedProjects: projects, fullScan: false)

case .error:
errorCount += 1

case .completed(let duration):
AppLogger.shared.info(
"Project parsing complete in \(String(format: "%.1f", duration))s",
category: "scan"
)
}
}

// Final removal sweep — mark projects not seen in this scan
projectScanStatusText = "Cleaning up..."
projectScanProgress = 0.85
let removedCount = try await reconciler.markMissingProjects(scannedPaths: allScannedPaths)
if removedCount > 0 {
AppLogger.shared.info(
"Marked \(removedCount) missing projects as removed",
category: "scan"
)
}

projectScanProgress = 0.95

let countDescriptor = FetchDescriptor<AbletonProject>(
predicate: #Predicate { !$0.isRemoved }
)
totalProjectCount = (try? modelContainer.mainContext.fetchCount(countDescriptor)) ?? 0

let allProjects = try? modelContainer.mainContext.fetch(
FetchDescriptor<AbletonProject>(
predicate: #Predicate { !$0.isRemoved }
)
)
projectsWithMissingPlugins = allProjects?
.filter { $0.missingPluginCount > 0 }.count ?? 0

projectScanProgress = 1.0

let totalDuration = Date().timeIntervalSince(scanStart)
AppLogger.shared.info(
"Project scan complete — \(totalProjectCount) projects, \(errorCount) errors in \(String(format: "%.1f", totalDuration))s",
category: "scan"
)
} catch {
AppLogger.shared.error(
"Project scan failed: \(error.localizedDescription)",
category: "scan"
)
}

projectScanStatusText = ""
isProjectScanning = false
}

func projectScanDirectories() -> [URL] {
let saved = UserDefaults.standard.stringArray(
forKey: Constants.UserDefaultsKeys.projectScanDirectories
)
let paths = saved ?? Constants.defaultProjectScanDirectories
return paths.map { URL(fileURLWithPath: $0) }
}

func startProjectMonitoring() {
guard UserDefaults.standard.bool(
forKey: Constants.UserDefaultsKeys.monitorProjectDirectories
) else { return }

projectFileMonitor?.stopMonitoring()
let monitor = FileSystemMonitor()
monitor.onDirectoriesChanged = { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await self.performProjectScan()
}
}
monitor.startMonitoring(directories: projectScanDirectories())
projectFileMonitor = monitor
}

func stopProjectMonitoring() {
projectFileMonitor?.stopMonitoring()
projectFileMonitor = nil
}

// MARK: - Private

private func enabledScanDirectories() throws -> [URL] {
Expand Down
12 changes: 11 additions & 1 deletion PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ struct PluginUpdaterApp: App {

CommandGroup(before: .help) {
Button("Open Logs Folder") {
NSWorkspace.shared.open(AppLogger.shared.logsDirectoryURL)
let url = AppLogger.shared.logsDirectoryURL
NSWorkspace.shared.open(
url,
configuration: NSWorkspace.OpenConfiguration()
) { _, _ in }
}
Divider()
}
Expand Down Expand Up @@ -122,5 +126,11 @@ struct PluginUpdaterApp: App {

// Start auto-scan timer
appState.startAutoScanTimer()

// Scan Ableton projects if enabled
if UserDefaults.standard.bool(forKey: Constants.UserDefaultsKeys.scanProjectsOnLaunch) {
await appState.performProjectScan()
}
appState.startProjectMonitoring()
}
}
46 changes: 46 additions & 0 deletions PluginUpdater/PluginUpdater/Models/AbletonProject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import SwiftData

@Model
final class AbletonProject {
@Attribute(.unique) var filePath: String
var name: String
var lastModified: Date
var fileSize: Int64
var abletonVersion: String?
@Relationship(deleteRule: .cascade, inverse: \AbletonProjectPlugin.project)
var plugins: [AbletonProjectPlugin] = []
var isRemoved: Bool = false
var lastScannedDate: Date?

init(
filePath: String,
name: String,
lastModified: Date,
fileSize: Int64,
abletonVersion: String? = nil,
isRemoved: Bool = false,
lastScannedDate: Date? = nil
) {
self.filePath = filePath
self.name = name
self.lastModified = lastModified
self.fileSize = fileSize
self.abletonVersion = abletonVersion
self.isRemoved = isRemoved
self.lastScannedDate = lastScannedDate
self.plugins = []
}

var fileURL: URL {
URL(fileURLWithPath: filePath)
}

var installedPluginCount: Int {
plugins.filter(\.isInstalled).count
}

var missingPluginCount: Int {
plugins.filter { !$0.isInstalled }.count
}
}
38 changes: 38 additions & 0 deletions PluginUpdater/PluginUpdater/Models/AbletonProjectPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import SwiftData

@Model
final class AbletonProjectPlugin {
var pluginName: String
var pluginType: String
var auComponentType: String?
var auComponentSubType: String?
var auComponentManufacturer: String?
var vst3TUID: String?
var vendorName: String?
var matchedPluginID: String?
var isInstalled: Bool = false
var project: AbletonProject?

init(
pluginName: String,
pluginType: String,
auComponentType: String? = nil,
auComponentSubType: String? = nil,
auComponentManufacturer: String? = nil,
vst3TUID: String? = nil,
vendorName: String? = nil,
matchedPluginID: String? = nil,
isInstalled: Bool = false
) {
self.pluginName = pluginName
self.pluginType = pluginType
self.auComponentType = auComponentType
self.auComponentSubType = auComponentSubType
self.auComponentManufacturer = auComponentManufacturer
self.vst3TUID = vst3TUID
self.vendorName = vendorName
self.matchedPluginID = matchedPluginID
self.isInstalled = isInstalled
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
PluginVersion.self,
VendorInfo.self,
ScanLocation.self,
AbletonProject.self,
AbletonProjectPlugin.self,
])

static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
Expand All @@ -29,7 +31,7 @@
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!

Check warning on line 34 in PluginUpdater/PluginUpdater/Services/Persistence/PersistenceController.swift

View workflow job for this annotation

GitHub Actions / build-and-test

Force unwrapping should be avoided (force_unwrapping)
let appDir = appSupport.appendingPathComponent("com.tomioueda.PluginUpdater")
try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true)
return appDir.appendingPathComponent("PluginUpdater.store")
Expand Down
Loading
Loading