diff --git a/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj b/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj index c014fd0..85f87bd 100644 --- a/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj +++ b/PluginUpdater/PluginUpdater.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 001056A58D6A044FE878C816 /* CPUArchitecture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E9F0D88FC395A0E299CABE /* CPUArchitecture.swift */; }; + 002ED17C38422E0665BDA70C /* AbletonProjectScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF4EF4A1B1F491B5FCB2107 /* AbletonProjectScannerTests.swift */; }; + 094A82A62070027682AB3EFC /* PluginMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C664FA0B7A7969EFAD0316E0 /* PluginMatcherTests.swift */; }; 0A69C6CDB779E830D34E4E81 /* PluginHideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54993856C5106C87AEBC386E /* PluginHideTests.swift */; }; 0C2B1176C0E94B366EEA9719 /* AppUpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC140E85F6B430D9D28E88F /* AppUpdateChecker.swift */; }; 0D301D58EFA2BE8EE6C3DCF5 /* UpdateManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A837F2C3F147FE3B2378743E /* UpdateManifestTests.swift */; }; @@ -28,6 +30,7 @@ 4B7C03DCD25941E0E9D4CE59 /* ProjectListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7F42BE5E5DA6496192F356 /* ProjectListView.swift */; }; 4C33669D95444617E54A5D63 /* AbletonProject.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB541C2C1BBAE25F36584A8F /* AbletonProject.swift */; }; 4D3ED583D835CC072BA442F7 /* ContextMenuActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE222F13CA653E287E98D190 /* ContextMenuActionTests.swift */; }; + 58FE6954E649DE2B614BC00D /* AbletonProjectModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990E16D0A171C0562103EE08 /* AbletonProjectModelTests.swift */; }; 5AC8E361BC1A63D3672BA750 /* vendor_urls.json in Resources */ = {isa = PBXBuildFile; fileRef = BE724FF345F7369F4059BB6E /* vendor_urls.json */; }; 5E74265A9645B66EB4C21E84 /* ProjectDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77BFAC4857A07DF1A9660DB8 /* ProjectDetailView.swift */; }; 5EC118195278CECA9A597B86 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC93F8EF57BA9A0FD9879280 /* AppState.swift */; }; @@ -37,17 +40,21 @@ 7341AFB665F38EDA0DD89450 /* ManifestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB464ABB49A24F29E6CF5654 /* ManifestManager.swift */; }; 77E5B68EC2A706F54DBE4439 /* URL+PluginBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 151EB46EC83CDD51442D51E8 /* URL+PluginBundle.swift */; }; 7BF3E307DB4608D962156F1E /* default_manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 2A275AFC90E301499BCBB80A /* default_manifest.json */; }; + 7E6A1152237E18A8C9A90059 /* AbletonProjectParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E70B64A002F6840CC260242D /* AbletonProjectParserTests.swift */; }; 7E95577656352305F6537C11 /* NSTableViewFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF4A30AE18D8FC6309341A9 /* NSTableViewFinder.swift */; }; 846D8655E139B8D9B814B8DE /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC8E0BFE748332634B82821 /* Plugin.swift */; }; 898EF245015E10839D80C33D /* PluginModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A56CA68C718D6EB1FD50D7 /* PluginModelTests.swift */; }; 899B613AB284F8F9D528EC0B /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5006207CA28B14BE7DFB20CD /* AppVersion.swift */; }; 8ECAB3C9ED34D1868CCF7FB7 /* VersionHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF0DAA61F5C1D3CA587AF319 /* VersionHistoryView.swift */; }; + 95949FB06F9BE8690F99E941 /* ProjectReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABA7B65965903CD781A23E7 /* ProjectReconcilerTests.swift */; }; 9E34A902CB91B58410878005 /* PluginFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B2F733E2F4A6FA4BFC9282 /* PluginFormat.swift */; }; + 9FBEF27BFFC36F3B978022BB /* ProjectDetailViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7260843A198DC8AF06D3BCDC /* ProjectDetailViewTests.swift */; }; A0789214418FC09BCFFA1A08 /* PluginDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C050396835BD7F636548ACE /* PluginDetailView.swift */; }; A4F965356550E03BE2D1254D /* AssetNamesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5642C14BB4CA023226BC86AA /* AssetNamesTests.swift */; }; A709FEEA68B7E388F1A5AAFD /* VendorLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BBAABB4C7CF313F0C5E8E0 /* VendorLink.swift */; }; A83F2BF484741B576EBFD528 /* ArchitectureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB60DE5B1E64F45E37BD8A1C /* ArchitectureDetectorTests.swift */; }; AE39E1893F3995A0372E46C8 /* UpdateStatusIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2A40B95918D92D1FAEB0BC /* UpdateStatusIndicator.swift */; }; + B08B71EF247DE2731E581B58 /* ProjectListViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF221F6C24157869E6F3E85 /* ProjectListViewTests.swift */; }; B26B9937EF5FB24735575B8C /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77DEF28C969390E7D1827F3D /* NotificationSettingsView.swift */; }; B49D299B461F137CCE71320E /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27AD453D9B6930C4A3717BED /* NotificationManager.swift */; }; B6220976895AFAD7563D0C4F /* URLExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE6380DB266015098CA2F47 /* URLExtensionTests.swift */; }; @@ -110,6 +117,7 @@ 40E9F0D88FC395A0E299CABE /* CPUArchitecture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUArchitecture.swift; sourceTree = ""; }; 443F8EF8565B79D25D4658BE /* String+Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Version.swift"; sourceTree = ""; }; 48945FF5154578D9FA574F84 /* PluginReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginReconciler.swift; sourceTree = ""; }; + 4ABA7B65965903CD781A23E7 /* ProjectReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectReconcilerTests.swift; sourceTree = ""; }; 5006207CA28B14BE7DFB20CD /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; 50B2F733E2F4A6FA4BFC9282 /* PluginFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginFormat.swift; sourceTree = ""; }; 54993856C5106C87AEBC386E /* PluginHideTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginHideTests.swift; sourceTree = ""; }; @@ -125,6 +133,7 @@ 68BBAABB4C7CF313F0C5E8E0 /* VendorLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorLink.swift; sourceTree = ""; }; 696F41E6C13E22B96BB66267 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 6BC8E0BFE748332634B82821 /* Plugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plugin.swift; sourceTree = ""; }; + 7260843A198DC8AF06D3BCDC /* ProjectDetailViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDetailViewTests.swift; sourceTree = ""; }; 77BFAC4857A07DF1A9660DB8 /* ProjectDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDetailView.swift; sourceTree = ""; }; 77DEF28C969390E7D1827F3D /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 7A598E6367944C87FE41AEE0 /* PluginReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginReconcilerTests.swift; sourceTree = ""; }; @@ -135,13 +144,16 @@ 8BC4908F51CDC729F0645FC6 /* PluginVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginVersion.swift; sourceTree = ""; }; 8FD735DCF3B89D813DB2D8CF /* UpdateManifestEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManifestEntry.swift; sourceTree = ""; }; 986676A91CA888FBD5DC549A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 990E16D0A171C0562103EE08 /* AbletonProjectModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProjectModelTests.swift; sourceTree = ""; }; 9AF4A30AE18D8FC6309341A9 /* NSTableViewFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTableViewFinder.swift; sourceTree = ""; }; 9C050396835BD7F636548ACE /* PluginDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginDetailView.swift; sourceTree = ""; }; 9EDFCF45CB9FA3E07DE63CF5 /* ScanLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanLocationTests.swift; sourceTree = ""; }; 9F4E2D6328514A432E594F40 /* VersionParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionParserTests.swift; sourceTree = ""; }; + 9FF4EF4A1B1F491B5FCB2107 /* AbletonProjectScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProjectScannerTests.swift; sourceTree = ""; }; A588CB8DFDCA92B11B7ECDC7 /* ScanLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanLocation.swift; sourceTree = ""; }; A837F2C3F147FE3B2378743E /* UpdateManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManifestTests.swift; sourceTree = ""; }; A8507964F3E9247BFC5BB580 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + AAF221F6C24157869E6F3E85 /* ProjectListViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectListViewTests.swift; sourceTree = ""; }; AB464ABB49A24F29E6CF5654 /* ManifestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestManager.swift; sourceTree = ""; }; AB541C2C1BBAE25F36584A8F /* AbletonProject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProject.swift; sourceTree = ""; }; B66AD28DDDD2ED9703BCD41B /* ArchitectureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchitectureDetector.swift; sourceTree = ""; }; @@ -150,6 +162,7 @@ BE724FF345F7369F4059BB6E /* vendor_urls.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vendor_urls.json; sourceTree = ""; }; C0075A029C426D72E02843DB /* PluginScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginScanner.swift; sourceTree = ""; }; C419BEFFE80A25F8F0CCC4E2 /* VersionCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionCheckerTests.swift; sourceTree = ""; }; + C664FA0B7A7969EFAD0316E0 /* PluginMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginMatcherTests.swift; sourceTree = ""; }; C87599BD5818534B5DD12E59 /* PluginUpdaterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PluginUpdaterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C8A56CA68C718D6EB1FD50D7 /* PluginModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginModelTests.swift; sourceTree = ""; }; CB60DE5B1E64F45E37BD8A1C /* ArchitectureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchitectureDetectorTests.swift; sourceTree = ""; }; @@ -157,6 +170,7 @@ CE00D48FF579CB2C859A32CF /* VendorURLResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorURLResolver.swift; sourceTree = ""; }; D3A38EDC8D6ED15E7BEF4646 /* cask_mappings.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = cask_mappings.json; sourceTree = ""; }; DA3A07860952E18E0DEC046B /* VendorResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorResolver.swift; sourceTree = ""; }; + E70B64A002F6840CC260242D /* AbletonProjectParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbletonProjectParserTests.swift; sourceTree = ""; }; EC93F8EF57BA9A0FD9879280 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; EE5E64D484AEBF8F443CC747 /* PluginUpdaterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginUpdaterApp.swift; sourceTree = ""; }; EF0DAA61F5C1D3CA587AF319 /* VersionHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionHistoryView.swift; sourceTree = ""; }; @@ -221,6 +235,8 @@ isa = PBXGroup; children = ( FE222F13CA653E287E98D190 /* ContextMenuActionTests.swift */, + 7260843A198DC8AF06D3BCDC /* ProjectDetailViewTests.swift */, + AAF221F6C24157869E6F3E85 /* ProjectListViewTests.swift */, 3C4F86DC778100292C275751 /* UpdateStatusIndicatorTests.swift */, ); path = Views; @@ -239,12 +255,16 @@ 4B79FF31AD704FCEE3410494 /* Services */ = { isa = PBXGroup; children = ( + E70B64A002F6840CC260242D /* AbletonProjectParserTests.swift */, + 9FF4EF4A1B1F491B5FCB2107 /* AbletonProjectScannerTests.swift */, F000674EA4300F88278E2F37 /* AppUpdateCheckerTests.swift */, CB60DE5B1E64F45E37BD8A1C /* ArchitectureDetectorTests.swift */, CBA6B4E9B3F58F0C720500CB /* BundleMetadataExtractorTests.swift */, 0F4C374ECB160B55AF5974F3 /* PersistenceControllerTests.swift */, + C664FA0B7A7969EFAD0316E0 /* PluginMatcherTests.swift */, 7A598E6367944C87FE41AEE0 /* PluginReconcilerTests.swift */, 17D4C0D1A9C8114B07C93E84 /* PluginScannerTests.swift */, + 4ABA7B65965903CD781A23E7 /* ProjectReconcilerTests.swift */, 5CE6380DB266015098CA2F47 /* URLExtensionTests.swift */, 5DBDCA9E8EEA702794CA532A /* VendorResolverTests.swift */, 058703EFBA8361D1A9CFE0E8 /* VendorURLResolverTests.swift */, @@ -326,6 +346,7 @@ 78FE303BA9B6455714F5405E /* Models */ = { isa = PBXGroup; children = ( + 990E16D0A171C0562103EE08 /* AbletonProjectModelTests.swift */, 02063DE3C8D3333A309332EC /* PluginFormatTests.swift */, 54993856C5106C87AEBC386E /* PluginHideTests.swift */, C8A56CA68C718D6EB1FD50D7 /* PluginModelTests.swift */, @@ -622,6 +643,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 58FE6954E649DE2B614BC00D /* AbletonProjectModelTests.swift in Sources */, + 7E6A1152237E18A8C9A90059 /* AbletonProjectParserTests.swift in Sources */, + 002ED17C38422E0665BDA70C /* AbletonProjectScannerTests.swift in Sources */, 2B88331B0AB8407AE5B3B5F9 /* AppUpdateCheckerTests.swift in Sources */, A83F2BF484741B576EBFD528 /* ArchitectureDetectorTests.swift in Sources */, A4F965356550E03BE2D1254D /* AssetNamesTests.swift in Sources */, @@ -630,9 +654,13 @@ D9D1FC24E55250860FF6AA5D /* PersistenceControllerTests.swift in Sources */, 3B77607C97AEA365C179293C /* PluginFormatTests.swift in Sources */, 0A69C6CDB779E830D34E4E81 /* PluginHideTests.swift in Sources */, + 094A82A62070027682AB3EFC /* PluginMatcherTests.swift in Sources */, 898EF245015E10839D80C33D /* PluginModelTests.swift in Sources */, C5B8D7AF3420606D0EC80F93 /* PluginReconcilerTests.swift in Sources */, C92E9EEF49370FF411973B90 /* PluginScannerTests.swift in Sources */, + 9FBEF27BFFC36F3B978022BB /* ProjectDetailViewTests.swift in Sources */, + B08B71EF247DE2731E581B58 /* ProjectListViewTests.swift in Sources */, + 95949FB06F9BE8690F99E941 /* ProjectReconcilerTests.swift in Sources */, C226CFE3B3914AF892FB57AA /* ScanLocationTests.swift in Sources */, B6220976895AFAD7563D0C4F /* URLExtensionTests.swift in Sources */, 0D301D58EFA2BE8EE6C3DCF5 /* UpdateManifestTests.swift in Sources */, diff --git a/PluginUpdater/PluginUpdater/App/AppState.swift b/PluginUpdater/PluginUpdater/App/AppState.swift index 0f7f7dd..d5b0763 100644 --- a/PluginUpdater/PluginUpdater/App/AppState.swift +++ b/PluginUpdater/PluginUpdater/App/AppState.swift @@ -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() @@ -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 = [] + 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( + predicate: #Predicate { !$0.isRemoved } + ) + totalProjectCount = (try? modelContainer.mainContext.fetchCount(countDescriptor)) ?? 0 + + let allProjects = try? modelContainer.mainContext.fetch( + FetchDescriptor( + 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] { diff --git a/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift b/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift index dba6753..a4caabd 100644 --- a/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift +++ b/PluginUpdater/PluginUpdater/App/PluginUpdaterApp.swift @@ -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() } @@ -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() } } diff --git a/PluginUpdater/PluginUpdater/Models/AbletonProject.swift b/PluginUpdater/PluginUpdater/Models/AbletonProject.swift new file mode 100644 index 0000000..3fb28a4 --- /dev/null +++ b/PluginUpdater/PluginUpdater/Models/AbletonProject.swift @@ -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 + } +} diff --git a/PluginUpdater/PluginUpdater/Models/AbletonProjectPlugin.swift b/PluginUpdater/PluginUpdater/Models/AbletonProjectPlugin.swift new file mode 100644 index 0000000..c846c8d --- /dev/null +++ b/PluginUpdater/PluginUpdater/Models/AbletonProjectPlugin.swift @@ -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 + } +} diff --git a/PluginUpdater/PluginUpdater/Services/Persistence/PersistenceController.swift b/PluginUpdater/PluginUpdater/Services/Persistence/PersistenceController.swift index 8665e9e..369a150 100644 --- a/PluginUpdater/PluginUpdater/Services/Persistence/PersistenceController.swift +++ b/PluginUpdater/PluginUpdater/Services/Persistence/PersistenceController.swift @@ -7,6 +7,8 @@ enum PersistenceController { PluginVersion.self, VendorInfo.self, ScanLocation.self, + AbletonProject.self, + AbletonProjectPlugin.self, ]) static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { diff --git a/PluginUpdater/PluginUpdater/Services/Persistence/ProjectReconciler.swift b/PluginUpdater/PluginUpdater/Services/Persistence/ProjectReconciler.swift new file mode 100644 index 0000000..60daeaf --- /dev/null +++ b/PluginUpdater/PluginUpdater/Services/Persistence/ProjectReconciler.swift @@ -0,0 +1,202 @@ +import Foundation +import SwiftData + +/// Reconciles parsed Ableton project data against the SwiftData store. +@ModelActor +actor ProjectReconciler { + + struct ReconciliationResult: Sendable { + let newProjects: Int + let updatedProjects: Int + let removedProjects: Int + let totalProcessed: Int + } + + /// Reconciles parsed Ableton projects against the SwiftData store. + func reconcile( + parsedProjects: [AbletonProjectParser.ParsedProject], + fullScan: Bool = true + ) throws -> ReconciliationResult { + let descriptor = FetchDescriptor() + let existingProjects = try modelContext.fetch(descriptor) + + var existingByPath: [String: AbletonProject] = [:] + for project in existingProjects { + existingByPath[project.filePath] = project + } + + let pluginDescriptor = FetchDescriptor( + predicate: #Predicate { !$0.isRemoved } + ) + let installedPlugins = try modelContext.fetch(pluginDescriptor) + let pluginIndex = PluginMatcher.PluginIndex(plugins: installedPlugins) + + var matchCache: [AbletonProjectParser.ParsedPlugin: PluginMatcher.MatchResult] = [:] + var seenPaths: Set = [] + var newCount = 0 + var updatedCount = 0 + var matchedCount = 0 + var unmatchedCount = 0 + + for parsed in parsedProjects { + seenPaths.insert(parsed.filePath) + + if let existing = existingByPath[parsed.filePath] { + existing.name = parsed.name + existing.lastModified = parsed.lastModified + existing.fileSize = parsed.fileSize + existing.abletonVersion = parsed.abletonVersion + existing.lastScannedDate = .now + existing.isRemoved = false + + for plugin in existing.plugins { + modelContext.delete(plugin) + } + existing.plugins = [] + + for parsedPlugin in parsed.plugins { + let projectPlugin = createProjectPlugin( + from: parsedPlugin, + index: pluginIndex, + cache: &matchCache + ) + existing.plugins.append(projectPlugin) + if projectPlugin.isInstalled { matchedCount += 1 } else { unmatchedCount += 1 } + } + + updatedCount += 1 + } else { + let project = AbletonProject( + filePath: parsed.filePath, + name: parsed.name, + lastModified: parsed.lastModified, + fileSize: parsed.fileSize, + abletonVersion: parsed.abletonVersion, + lastScannedDate: .now + ) + modelContext.insert(project) + + for parsedPlugin in parsed.plugins { + let projectPlugin = createProjectPlugin( + from: parsedPlugin, + index: pluginIndex, + cache: &matchCache + ) + project.plugins.append(projectPlugin) + if projectPlugin.isInstalled { matchedCount += 1 } else { unmatchedCount += 1 } + } + + newCount += 1 + } + } + + var removedCount = 0 + if fullScan { + for project in existingProjects + where !seenPaths.contains(project.filePath) && !project.isRemoved { + project.isRemoved = true + removedCount += 1 + } + } + + try modelContext.save() + + AppLogger.shared.info( + "Reconciled \(parsedProjects.count) projects — \(matchedCount) matched, \(unmatchedCount) unmatched, \(newCount) new, \(updatedCount) updated, \(removedCount) removed", + category: "projectScan" + ) + + return ReconciliationResult( + newProjects: newCount, + updatedProjects: updatedCount, + removedProjects: removedCount, + totalProcessed: parsedProjects.count + ) + } + + /// Marks projects whose paths are not in `scannedPaths` as removed. + /// Used after streaming scan completes to sweep missing projects. + func markMissingProjects(scannedPaths: Set) throws -> Int { + let descriptor = FetchDescriptor() + let existingProjects = try modelContext.fetch(descriptor) + + var removedCount = 0 + for project in existingProjects + where !scannedPaths.contains(project.filePath) && !project.isRemoved { + project.isRemoved = true + removedCount += 1 + } + + try modelContext.save() + return removedCount + } + + /// Re-runs plugin matching for all projects against current installed plugins. + /// Call after a plugin scan completes to update isInstalled flags. + func refreshPluginMatching() throws { + let projectDescriptor = FetchDescriptor( + predicate: #Predicate { !$0.isRemoved } + ) + let projects = try modelContext.fetch(projectDescriptor) + + let pluginDescriptor = FetchDescriptor( + predicate: #Predicate { !$0.isRemoved } + ) + let installedPlugins = try modelContext.fetch(pluginDescriptor) + let pluginIndex = PluginMatcher.PluginIndex(plugins: installedPlugins) + var matchCache: [AbletonProjectParser.ParsedPlugin: PluginMatcher.MatchResult] = [:] + + for project in projects { + for projectPlugin in project.plugins { + let parsed = AbletonProjectParser.ParsedPlugin( + pluginName: projectPlugin.pluginName, + pluginType: projectPlugin.pluginType, + auComponentType: projectPlugin.auComponentType, + auComponentSubType: projectPlugin.auComponentSubType, + auComponentManufacturer: projectPlugin.auComponentManufacturer, + vst3TUID: projectPlugin.vst3TUID, + vendorName: projectPlugin.vendorName + ) + let result: PluginMatcher.MatchResult + if let cached = matchCache[parsed] { + result = cached + } else { + result = PluginMatcher.match(parsed, index: pluginIndex) + matchCache[parsed] = result + } + projectPlugin.isInstalled = result.isInstalled + projectPlugin.matchedPluginID = result.matchedPluginID + } + } + + try modelContext.save() + } + + // MARK: - Private + + private func createProjectPlugin( + from parsed: AbletonProjectParser.ParsedPlugin, + index: PluginMatcher.PluginIndex, + cache: inout [AbletonProjectParser.ParsedPlugin: PluginMatcher.MatchResult] + ) -> AbletonProjectPlugin { + let matchResult: PluginMatcher.MatchResult + if let cached = cache[parsed] { + matchResult = cached + } else { + matchResult = PluginMatcher.match(parsed, index: index) + cache[parsed] = matchResult + } + + return AbletonProjectPlugin( + pluginName: parsed.pluginName, + pluginType: parsed.pluginType, + auComponentType: parsed.auComponentType, + auComponentSubType: parsed.auComponentSubType, + auComponentManufacturer: parsed.auComponentManufacturer, + vst3TUID: parsed.vst3TUID, + vendorName: parsed.vendorName, + matchedPluginID: matchResult.matchedPluginID, + isInstalled: matchResult.isInstalled + ) + } +} diff --git a/PluginUpdater/PluginUpdater/Services/Scanner/AbletonProjectParser.swift b/PluginUpdater/PluginUpdater/Services/Scanner/AbletonProjectParser.swift new file mode 100644 index 0000000..1f65ef0 --- /dev/null +++ b/PluginUpdater/PluginUpdater/Services/Scanner/AbletonProjectParser.swift @@ -0,0 +1,359 @@ +import Foundation +import zlib + +/// Parses Ableton Live .als files (gzip-compressed XML) to extract plugin references. +actor AbletonProjectParser { + + struct ParsedProject: Sendable { + let name: String + let filePath: String + let lastModified: Date + let fileSize: Int64 + let abletonVersion: String? + let plugins: [ParsedPlugin] + } + + struct ParsedPlugin: Sendable, Hashable { + let pluginName: String + let pluginType: String + let auComponentType: String? + let auComponentSubType: String? + let auComponentManufacturer: String? + let vst3TUID: String? + let vendorName: String? + + func hash(into hasher: inout Hasher) { + hasher.combine(pluginType) + switch pluginType { + case "au": + hasher.combine(auComponentSubType) + hasher.combine(auComponentManufacturer) + case "vst3": + hasher.combine(vst3TUID) + default: + hasher.combine(pluginName.lowercased()) + } + } + + static func == (lhs: ParsedPlugin, rhs: ParsedPlugin) -> Bool { + if lhs.pluginType != rhs.pluginType { return false } + switch lhs.pluginType { + case "au": + return lhs.auComponentSubType == rhs.auComponentSubType && + lhs.auComponentManufacturer == rhs.auComponentManufacturer + case "vst3": + return lhs.vst3TUID == rhs.vst3TUID + default: + return lhs.pluginName.lowercased() == rhs.pluginName.lowercased() + } + } + } + + enum ParseError: Error { + case fileNotFound(URL) + case decompressFailed(URL) + case xmlParseFailed(URL, String) + } + + func parse(fileURL: URL) throws -> ParsedProject { + let fm = FileManager.default + guard fm.fileExists(atPath: fileURL.path) else { + throw ParseError.fileNotFound(fileURL) + } + + let attrs = try fm.attributesOfItem(atPath: fileURL.path) + let fileSize = (attrs[.size] as? Int64) ?? 0 + let lastModified = (attrs[.modificationDate] as? Date) ?? .now + + let compressedData = try Data(contentsOf: fileURL) + guard let xmlData = decompressGzip(compressedData) else { + throw ParseError.decompressFailed(fileURL) + } + + let delegate = ALSXMLDelegate() + let parser = XMLParser(data: xmlData) + parser.delegate = delegate + + guard parser.parse() else { + let errorMsg = parser.parserError?.localizedDescription ?? "Unknown parse error" + throw ParseError.xmlParseFailed(fileURL, errorMsg) + } + + let uniquePlugins = Array(Set(delegate.plugins)) + let projectName = fileURL.deletingPathExtension().lastPathComponent + + if AppLogger.shared.verbose { + AppLogger.shared.info( + "Parsed \(projectName): \(uniquePlugins.count) plugins (from \(delegate.plugins.count) raw)", + category: "projectScan" + ) + } + + return ParsedProject( + name: projectName, + filePath: fileURL.path, + lastModified: lastModified, + fileSize: fileSize, + abletonVersion: delegate.abletonVersion, + plugins: uniquePlugins + ) + } + + // MARK: - Gzip Decompression + + private func decompressGzip(_ data: Data) -> Data? { + guard data.count >= 2, data[0] == 0x1f, data[1] == 0x8b else { + return data // Not gzip — return as-is (plain XML) + } + + var stream = z_stream() + // windowBits = 15 + 32 tells zlib to auto-detect gzip/zlib headers + guard inflateInit2_(&stream, 15 + 32, ZLIB_VERSION, Int32(MemoryLayout.size)) == Z_OK else { + return nil + } + defer { inflateEnd(&stream) } + + var output = Data(capacity: data.count * 4) + + return data.withUnsafeBytes { inputPtr -> Data? in + guard let baseAddress = inputPtr.baseAddress else { return nil } + stream.next_in = UnsafeMutablePointer(mutating: baseAddress.assumingMemoryBound(to: UInt8.self)) + stream.avail_in = UInt32(data.count) + + let chunkSize = 65_536 + let buffer = UnsafeMutablePointer.allocate(capacity: chunkSize) + defer { buffer.deallocate() } + + repeat { + stream.next_out = buffer + stream.avail_out = UInt32(chunkSize) + + let status = inflate(&stream, Z_NO_FLUSH) + guard status == Z_OK || status == Z_STREAM_END else { return nil } + + let bytesWritten = chunkSize - Int(stream.avail_out) + output.append(buffer, count: bytesWritten) + + if status == Z_STREAM_END { break } + } while stream.avail_out == 0 + + return output + } + } +} + +// MARK: - SAX XML Delegate + +private final class ALSXMLDelegate: NSObject, XMLParserDelegate { + var abletonVersion: String? + var plugins: [AbletonProjectParser.ParsedPlugin] = [] + + private var elementStack: [String] = [] + + // AU plugin state + private var inAuPluginInfo = false + private var auPluginInfoDepth = 0 + private var auName: String? + private var auManufacturer: String? + private var auComponentType: String? + private var auComponentSubType: String? + private var auComponentManufacturer: String? + + // VST3 plugin state + private var inVst3PluginInfo = false + private var vst3PluginInfoDepth = 0 + private var vst3Name: String? + private var vst3UidFields: [String: String] = [:] + + // VST2 plugin state + private var inVstPluginInfo = false + private var vst2PlugName: String? + + // Context for VST3 name resolution + private var lastBranchPresetName: String? + private var currentPluginDeviceName: String? + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName: String?, + attributes: [String: String] + ) { + elementStack.append(elementName) + + // Root element has Ableton version + if elementName == "Ableton" { + abletonVersion = attributes["Creator"] + } + + // Track PluginDevice UserName for VST3 name resolution + if elementName == "PluginDevice" { + currentPluginDeviceName = nil + } + if elementName == "UserName" && elementStack.dropLast().last == "PluginDevice" { + currentPluginDeviceName = attributes["Value"] + } + + // AU Plugin — track nesting depth to distinguish direct children + // from elements inside nested blocks + if elementName == "AuPluginInfo" { + inAuPluginInfo = true + auPluginInfoDepth = elementStack.count + auName = nil; auManufacturer = nil + auComponentType = nil; auComponentSubType = nil; auComponentManufacturer = nil + } + if inAuPluginInfo { + let depthInBlock = elementStack.count - auPluginInfoDepth + // Only capture direct children of AuPluginInfo (depth 1) + if depthInBlock == 1 { + if elementName == "Name" { auName = attributes["Value"] } + if elementName == "Manufacturer" { auManufacturer = attributes["Value"] } + if elementName == "ComponentType" { + if let v = attributes["Value"], let num = UInt32(v) { + auComponentType = fourCharCode(from: num) + } + } + if elementName == "ComponentSubType" { + if let v = attributes["Value"], let num = UInt32(v) { + auComponentSubType = fourCharCode(from: num) + } + } + if elementName == "ComponentManufacturer" { + if let v = attributes["Value"], let num = UInt32(v) { + auComponentManufacturer = fourCharCode(from: num) + } + } + } + } + + // VST3 Plugin — track nesting depth to distinguish direct children + // from elements inside nested blocks + if elementName == "Vst3PluginInfo" { + inVst3PluginInfo = true + vst3PluginInfoDepth = elementStack.count + vst3Name = nil + vst3UidFields = [:] + } + if inVst3PluginInfo { + let depthInBlock = elementStack.count - vst3PluginInfoDepth + // Direct children of Vst3PluginInfo are at depth 1 + if elementName == "Name" && depthInBlock == 1 { + vst3Name = attributes["Value"] + } + // Uid/Fields at depth 1-2 (Uid is depth 1, Fields.N is depth 2) + if elementName.hasPrefix("Fields.") && depthInBlock == 2 { + // Only capture if parent is Uid directly under Vst3PluginInfo + if elementStack.dropLast().last == "Uid" { + vst3UidFields[elementName] = attributes["Value"] + } + } + } + + // VST2 Plugin + if elementName == "VstPluginInfo" { + inVstPluginInfo = true + vst2PlugName = nil + } + if inVstPluginInfo && elementName == "PlugName" { + vst2PlugName = attributes["Value"] + } + + // Track BranchPresetName for VST3 name resolution + if elementName == "BranchPresetName" { + lastBranchPresetName = attributes["Value"] + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName: String? + ) { + if elementName == "AuPluginInfo" && inAuPluginInfo { + inAuPluginInfo = false + if let name = auName, !name.isEmpty { + plugins.append(AbletonProjectParser.ParsedPlugin( + pluginName: name, + pluginType: "au", + auComponentType: auComponentType, + auComponentSubType: auComponentSubType, + auComponentManufacturer: auComponentManufacturer, + vst3TUID: nil, + vendorName: auManufacturer + )) + } + } + + if elementName == "Vst3PluginInfo" && inVst3PluginInfo { + inVst3PluginInfo = false + let tuid = buildVST3TUID(from: vst3UidFields) + let resolvedName = vst3Name + ?? currentPluginDeviceName + ?? lastBranchPresetName + ?? "Unknown VST3" + if !resolvedName.isEmpty && !vst3UidFields.isEmpty { + plugins.append(AbletonProjectParser.ParsedPlugin( + pluginName: resolvedName, + pluginType: "vst3", + auComponentType: nil, + auComponentSubType: nil, + auComponentManufacturer: nil, + vst3TUID: tuid, + vendorName: nil + )) + } + lastBranchPresetName = nil + currentPluginDeviceName = nil + } + + if elementName == "VstPluginInfo" && inVstPluginInfo { + inVstPluginInfo = false + if let name = vst2PlugName, !name.isEmpty { + plugins.append(AbletonProjectParser.ParsedPlugin( + pluginName: name, + pluginType: "vst2", + auComponentType: nil, + auComponentSubType: nil, + auComponentManufacturer: nil, + vst3TUID: nil, + vendorName: nil + )) + } + } + + _ = elementStack.popLast() + } + + // MARK: - Helpers + + private func fourCharCode(from value: UInt32) -> String { + let bytes = withUnsafeBytes(of: value.bigEndian) { Array($0) } + return String(bytes.compactMap { + $0 >= 0x20 && $0 <= 0x7E ? Character(UnicodeScalar($0)) : nil + }) + } + + private func buildVST3TUID(from fields: [String: String]) -> String? { + guard let f0 = fields["Fields.0"], let v0 = parseUInt32(f0), + let f1 = fields["Fields.1"], let v1 = parseUInt32(f1), + let f2 = fields["Fields.2"], let v2 = parseUInt32(f2), + let f3 = fields["Fields.3"], let v3 = parseUInt32(f3) else { + return nil + } + return String(format: "%08X%08X%08X%08X", v0, v1, v2, v3) + } + + /// Parses a string that may be a signed or unsigned 32-bit integer. + /// Ableton .als files store TUID fields as signed Int32 values (can be negative). + private func parseUInt32(_ string: String) -> UInt32? { + if let unsigned = UInt32(string) { + return unsigned + } + if let signed = Int32(string) { + return UInt32(bitPattern: signed) + } + return nil + } +} diff --git a/PluginUpdater/PluginUpdater/Services/Scanner/AbletonProjectScanner.swift b/PluginUpdater/PluginUpdater/Services/Scanner/AbletonProjectScanner.swift new file mode 100644 index 0000000..c9c2cc8 --- /dev/null +++ b/PluginUpdater/PluginUpdater/Services/Scanner/AbletonProjectScanner.swift @@ -0,0 +1,275 @@ +import Foundation + +/// Discovers and parses Ableton Live project (.als) files from configured directories. +actor AbletonProjectScanner { + struct ScanResult: Sendable { + let projects: [AbletonProjectParser.ParsedProject] + let errors: [ScanError] + let duration: TimeInterval + } + + struct ScanError: Error, Sendable { + let url: URL + let message: String + } + + enum ScanProgress: Sendable { + case discovering(directory: String) + case parsing(current: Int, total: Int, projectName: String) + } + + enum StreamEvent: Sendable { + case progress(ScanProgress) + case batch([AbletonProjectParser.ParsedProject]) + case error(ScanError) + case completed(duration: TimeInterval) + } + + private let concurrency: Int + + init(concurrency: Int = Constants.Defaults.scanConcurrency) { + self.concurrency = concurrency + } + + /// Performs a full scan of all provided directories for .als files. + func scan( + directories: [URL], + onProgress: (@Sendable (ScanProgress) -> Void)? = nil + ) async -> ScanResult { + let start = Date() + var allALSFiles: [URL] = [] + + for directory in directories { + onProgress?(.discovering(directory: directory.lastPathComponent)) + let files = discoverALSFiles(in: directory) + allALSFiles.append(contentsOf: files) + } + + let (projects, errors) = await parseProjects(from: allALSFiles, onProgress: onProgress) + let duration = Date().timeIntervalSince(start) + + return ScanResult(projects: projects, errors: errors, duration: duration) + } + + /// Streaming scan that yields batches of parsed projects as they complete. + /// The caller receives `.batch` events every `batchSize` completions. + func scanStreaming( + directories: [URL], + batchSize: Int = 20 + ) -> AsyncStream { + let concurrencyLimit = self.concurrency + return AsyncStream { continuation in + Task { + let start = Date() + var allALSFiles: [URL] = [] + + for directory in directories { + continuation.yield(.progress(.discovering(directory: directory.lastPathComponent))) + let files = self.discoverALSFiles(in: directory) + allALSFiles.append(contentsOf: files) + } + + let total = allALSFiles.count + var completed = 0 + var batch: [AbletonProjectParser.ParsedProject] = [] + let parser = AbletonProjectParser() + + await withTaskGroup(of: Result.self) { group in + var inFlight = 0 + + func processBatch(_ batch: inout [AbletonProjectParser.ParsedProject]) { + if !batch.isEmpty { + continuation.yield(.batch(batch)) + batch.removeAll(keepingCapacity: true) + } + } + + func handleResult( + _ result: Result, + batch: inout [AbletonProjectParser.ParsedProject], + completed: inout Int, + total: Int + ) { + completed += 1 + switch result { + case .success(let project): + batch.append(project) + continuation.yield(.progress(.parsing( + current: completed, + total: total, + projectName: project.name + ))) + if batch.count >= batchSize { + processBatch(&batch) + } + case .failure(let error): + continuation.yield(.error(error)) + continuation.yield(.progress(.parsing( + current: completed, + total: total, + projectName: error.url.deletingPathExtension().lastPathComponent + ))) + } + } + + for url in allALSFiles { + if inFlight >= concurrencyLimit { + if let result = await group.next() { + inFlight -= 1 + handleResult(result, batch: &batch, completed: &completed, total: total) + } + } + + group.addTask { + do { + let project = try await parser.parse(fileURL: url) + return .success(project) + } catch { + return .failure(ScanError(url: url, message: error.localizedDescription)) + } + } + inFlight += 1 + } + + for await result in group { + handleResult(result, batch: &batch, completed: &completed, total: total) + } + + // Flush remaining + processBatch(&batch) + } + + let duration = Date().timeIntervalSince(start) + continuation.yield(.completed(duration: duration)) + continuation.finish() + } + } + } + + /// Incremental scan: only parse files modified after a given date. + func scanIncremental( + directories: [URL], + modifiedAfter: Date?, + onProgress: (@Sendable (ScanProgress) -> Void)? = nil + ) async -> ScanResult { + let start = Date() + var allALSFiles: [URL] = [] + + for directory in directories { + onProgress?(.discovering(directory: directory.lastPathComponent)) + let files = discoverALSFiles(in: directory) + if let cutoff = modifiedAfter { + let filtered = files.filter { url in + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let modDate = attrs[.modificationDate] as? Date else { return true } + return modDate > cutoff + } + allALSFiles.append(contentsOf: filtered) + } else { + allALSFiles.append(contentsOf: files) + } + } + + let (projects, errors) = await parseProjects(from: allALSFiles, onProgress: onProgress) + let duration = Date().timeIntervalSince(start) + + return ScanResult(projects: projects, errors: errors, duration: duration) + } + + /// Discovers all .als files recursively in a directory. + nonisolated func discoverALSFiles(in directory: URL) -> [URL] { + let fm = FileManager.default + guard fm.fileExists(atPath: directory.path) else { return [] } + + var files: [URL] = [] + guard let enumerator = fm.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + while let url = enumerator.nextObject() as? URL { + if url.pathExtension.lowercased() == "als" { + files.append(url) + } + } + + return files.sorted { url1, url2 in + let size1 = (try? url1.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? Int.max + let size2 = (try? url2.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? Int.max + return size1 < size2 + } + } + + private func parseProjects( + from urls: [URL], + onProgress: (@Sendable (ScanProgress) -> Void)? = nil + ) async -> ([AbletonProjectParser.ParsedProject], [ScanError]) { + var projects: [AbletonProjectParser.ParsedProject] = [] + var errors: [ScanError] = [] + let parser = AbletonProjectParser() + let total = urls.count + var completed = 0 + + await withTaskGroup(of: Result.self) { group in + var inFlight = 0 + + for url in urls { + if inFlight >= concurrency { + if let result = await group.next() { + inFlight -= 1 + completed += 1 + switch result { + case .success(let project): + projects.append(project) + onProgress?(.parsing( + current: completed, + total: total, + projectName: project.name + )) + case .failure(let error): + errors.append(error) + onProgress?(.parsing( + current: completed, + total: total, + projectName: error.url.deletingPathExtension().lastPathComponent + )) + } + } + } + + group.addTask { + do { + let project = try await parser.parse(fileURL: url) + return .success(project) + } catch { + return .failure(ScanError(url: url, message: error.localizedDescription)) + } + } + inFlight += 1 + } + + for await result in group { + completed += 1 + switch result { + case .success(let project): + projects.append(project) + onProgress?(.parsing( + current: completed, + total: total, + projectName: project.name + )) + case .failure(let error): + errors.append(error) + onProgress?(.parsing( + current: completed, + total: total, + projectName: error.url.deletingPathExtension().lastPathComponent + )) + } + } + } + + return (projects, errors) + } +} diff --git a/PluginUpdater/PluginUpdater/Services/Scanner/PluginMatcher.swift b/PluginUpdater/PluginUpdater/Services/Scanner/PluginMatcher.swift new file mode 100644 index 0000000..58654be --- /dev/null +++ b/PluginUpdater/PluginUpdater/Services/Scanner/PluginMatcher.swift @@ -0,0 +1,238 @@ +import Foundation +import SwiftData + +/// Matches plugin references extracted from Ableton projects against installed plugins. +enum PluginMatcher { + + struct MatchResult { + let matchedPluginID: String? + let isInstalled: Bool + } + + /// Pre-built index for O(1) plugin lookups. Build once before a matching loop. + struct PluginIndex { + let byFormatAndName: [String: Plugin] + let byNameLower: [String: [Plugin]] + let byNormalizedName: [String: Plugin] + let byVendorLower: [String: [Plugin]] + let allActive: [Plugin] + + init(plugins: [Plugin]) { + var formatAndName: [String: Plugin] = [:] + var nameLower: [String: [Plugin]] = [:] + var normalized: [String: Plugin] = [:] + var vendorLower: [String: [Plugin]] = [:] + var active: [Plugin] = [] + + for plugin in plugins where !plugin.isRemoved { + active.append(plugin) + + let key = "\(plugin.format.rawValue):\(plugin.name.lowercased())" + formatAndName[key] = plugin + + let lower = plugin.name.lowercased() + nameLower[lower, default: []].append(plugin) + + let norm = lower.filter { $0.isLetter || $0.isNumber } + if norm.count >= 3 { + normalized[norm] = plugin + } + + let vendor = plugin.vendorName.lowercased() + if !vendor.isEmpty { + vendorLower[vendor, default: []].append(plugin) + } + } + + self.byFormatAndName = formatAndName + self.byNameLower = nameLower + self.byNormalizedName = normalized + self.byVendorLower = vendorLower + self.allActive = active + } + } + + /// Matches a parsed plugin reference using a pre-built index. Preferred for batch matching. + static func match( + _ parsed: AbletonProjectParser.ParsedPlugin, + index: PluginIndex + ) -> MatchResult { + let result: MatchResult + switch parsed.pluginType { + case "au": + result = matchAU(parsed, index: index) + case "vst3": + result = matchVST3(parsed, index: index) + case "vst2": + result = matchVST2(parsed, index: index) + default: + result = MatchResult(matchedPluginID: nil, isInstalled: false) + } + + if !result.isInstalled && AppLogger.shared.verbose { + AppLogger.shared.info( + " UNMATCHED [\(parsed.pluginType)] \"\(parsed.pluginName)\"\(parsed.vendorName.map { " vendor=\($0)" } ?? "")", + category: "projectScan" + ) + } + + return result + } + + /// Backward-compatible overload that builds a temporary index. + static func match( + _ parsed: AbletonProjectParser.ParsedPlugin, + installedPlugins: [Plugin] + ) -> MatchResult { + let index = PluginIndex(plugins: installedPlugins) + return match(parsed, index: index) + } + + // MARK: - AU Matching + + private static func matchAU( + _ parsed: AbletonProjectParser.ParsedPlugin, + index: PluginIndex + ) -> MatchResult { + // Format-specific exact match + let key = "au:\(parsed.pluginName.lowercased())" + if let match = index.byFormatAndName[key] { + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + + // Vendor-scoped fuzzy: if vendor matches, try stripped-name substring match + if let vendor = parsed.vendorName { + let vendorLower = vendor.lowercased() + let strippedName = parsed.pluginName.replacingOccurrences( + of: #"\s*\d+(\.\d+)*$"#, + with: "", + options: .regularExpression + ) + if strippedName != parsed.pluginName { + if let vendorPlugins = index.byVendorLower[vendorLower] { + let auVendorPlugins = vendorPlugins.filter { $0.format == .au } + if let match = auVendorPlugins.first(where: { + $0.name.localizedCaseInsensitiveContains(strippedName) + }) { + if AppLogger.shared.verbose { + AppLogger.shared.info( + " FUZZY MATCH (vendor+strip) \"\(parsed.pluginName)\" -> \"\(match.name)\"", + category: "projectScan" + ) + } + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + } + } + } + + return matchByName(parsed, index: index) + } + + // MARK: - VST3 Matching + + private static func matchVST3( + _ parsed: AbletonProjectParser.ParsedPlugin, + index: PluginIndex + ) -> MatchResult { + let key = "vst3:\(parsed.pluginName.lowercased())" + if let match = index.byFormatAndName[key] { + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + + return matchByName(parsed, index: index) + } + + // MARK: - VST2 Matching + + private static func matchVST2( + _ parsed: AbletonProjectParser.ParsedPlugin, + index: PluginIndex + ) -> MatchResult { + let key = "vst2:\(parsed.pluginName.lowercased())" + if let match = index.byFormatAndName[key] { + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + + return matchByName(parsed, index: index) + } + + // MARK: - Fallback Name Matching + + /// Cross-format name matching with O(1) dictionary lookups where possible, + /// falling back to linear scans only for substring/contains matches. + private static func matchByName( + _ parsed: AbletonProjectParser.ParsedPlugin, + index: PluginIndex + ) -> MatchResult { + let searchName = parsed.pluginName.lowercased() + + // Exact case-insensitive match (O(1) via dictionary) + if let matches = index.byNameLower[searchName], let match = matches.first { + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + + // Contains match (linear scan — only reached for non-exact names) + if let match = index.allActive.first(where: { + $0.name.lowercased().contains(searchName) + }) { + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + + // Reverse contains + if let match = index.allActive.first(where: { + searchName.contains($0.name.lowercased()) + }) { + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + + // Version suffix stripping + let strippedName = parsed.pluginName.replacingOccurrences( + of: #"\s*\d+(\.\d+)*$"#, + with: "", + options: .regularExpression + ) + if strippedName != parsed.pluginName && !strippedName.isEmpty { + let strippedLower = strippedName.lowercased() + + // Exact match on stripped name (O(1)) + if let matches = index.byNameLower[strippedLower], let match = matches.first { + if AppLogger.shared.verbose { + AppLogger.shared.info( + " FUZZY MATCH (version strip) \"\(parsed.pluginName)\" -> \"\(match.name)\"", + category: "projectScan" + ) + } + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + // Contains with stripped name (linear fallback) + if let match = index.allActive.first(where: { + $0.name.lowercased().contains(strippedLower) + }) { + if AppLogger.shared.verbose { + AppLogger.shared.info( + " FUZZY MATCH (version strip + contains) \"\(parsed.pluginName)\" -> \"\(match.name)\"", + category: "projectScan" + ) + } + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + } + + // Alphanumeric normalization (O(1) via dictionary) + let normalizedSearch = parsed.pluginName.lowercased().filter { $0.isLetter || $0.isNumber } + if normalizedSearch.count >= 3 { + if let match = index.byNormalizedName[normalizedSearch] { + if AppLogger.shared.verbose { + AppLogger.shared.info( + " FUZZY MATCH (alphanumeric) \"\(parsed.pluginName)\" -> \"\(match.name)\"", + category: "projectScan" + ) + } + return MatchResult(matchedPluginID: "\(match.id)", isInstalled: true) + } + } + + return MatchResult(matchedPluginID: nil, isInstalled: false) + } +} diff --git a/PluginUpdater/PluginUpdater/Utilities/AppLogger.swift b/PluginUpdater/PluginUpdater/Utilities/AppLogger.swift index 8775c81..ffda415 100644 --- a/PluginUpdater/PluginUpdater/Utilities/AppLogger.swift +++ b/PluginUpdater/PluginUpdater/Utilities/AppLogger.swift @@ -6,11 +6,27 @@ import OSLog final class AppLogger { static let shared = AppLogger() + /// When true, per-plugin matching details are logged. Off by default. + /// Toggle via `defaults write com.tomioueda.PluginUpdater debugVerboseLogging -bool YES` + var verbose: Bool = false + private let osLog = Logger(subsystem: "com.tomioueda.PluginUpdater", category: "app") private let queue = DispatchQueue(label: "com.tomioueda.PluginUpdater.logger", qos: .utility) private var fileHandle: FileHandle? private var currentLogDate: String = "" + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() + + private static let timestampFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + return f + }() + let logsDirectoryURL: URL = { let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] return base.appendingPathComponent("Logs/PluginUpdater", isDirectory: true) @@ -71,7 +87,7 @@ final class AppLogger { } private func openFileForToday() { - let dateString = todayString() + let dateString = Self.dayFormatter.string(from: Date()) guard dateString != currentLogDate else { return } fileHandle?.closeFile() @@ -88,26 +104,14 @@ final class AppLogger { } private func writeToFile(_ message: String, level: String, category: String) { - let dateString = todayString() + let dateString = Self.dayFormatter.string(from: Date()) if dateString != currentLogDate { openFileForToday() } - let timestamp = timestampString() + let timestamp = Self.timestampFormatter.string(from: Date()) let line = "[\(timestamp)] [\(level)] [\(category)] \(message)\n" guard let data = line.data(using: .utf8) else { return } fileHandle?.write(data) } - - private func todayString() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: Date()) - } - - private func timestampString() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - return formatter.string(from: Date()) - } } diff --git a/PluginUpdater/PluginUpdater/Utilities/Constants.swift b/PluginUpdater/PluginUpdater/Utilities/Constants.swift index 77de541..661a165 100644 --- a/PluginUpdater/PluginUpdater/Utilities/Constants.swift +++ b/PluginUpdater/PluginUpdater/Utilities/Constants.swift @@ -3,7 +3,7 @@ import Foundation enum Constants { enum Defaults { static let scanFrequencyMinutes = 60 - static let scanConcurrency = 8 + static let scanConcurrency = 16 static let fsEventsDebounceSeconds: TimeInterval = 3.0 static let manifestURL = "" } @@ -18,6 +18,10 @@ enum Constants { static let notifyNewPlugins = "notifyNewPlugins" static let notifyUpdatedPlugins = "notifyUpdatedPlugins" static let notifyRemovedPlugins = "notifyRemovedPlugins" + static let projectScanDirectories = "projectScanDirectories" + static let scanProjectsOnLaunch = "scanProjectsOnLaunch" + static let monitorProjectDirectories = "monitorProjectDirectories" + static let debugVerboseLogging = "debugVerboseLogging" static let checkForAppUpdates = "checkForAppUpdates" } @@ -45,4 +49,9 @@ enum Constants { ("/Library/Audio/Plug-Ins/VST", .vst2), ("/Library/Audio/Plug-Ins/VST3", .vst3), ] + + static let defaultProjectScanDirectories: [String] = { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return ["\(home)/Documents/Ableton Projects"] + }() } diff --git a/PluginUpdater/PluginUpdater/Views/Dashboard/DashboardView.swift b/PluginUpdater/PluginUpdater/Views/Dashboard/DashboardView.swift index b699521..ce36c4b 100644 --- a/PluginUpdater/PluginUpdater/Views/Dashboard/DashboardView.swift +++ b/PluginUpdater/PluginUpdater/Views/Dashboard/DashboardView.swift @@ -7,6 +7,10 @@ enum SidebarFilter: Hashable { case format(PluginFormat) case updatesAvailable case hidden + case allProjects + case projectsMissingPlugins + case usedPlugins + case unusedPlugins } /// Wraps a Plugin with its computed update status so the Table can sort all columns. @@ -45,6 +49,10 @@ private struct SidebarCounts { var hidden = 0 var updates = 0 var formatCounts: [PluginFormat: Int] = [:] + var totalProjects = 0 + var projectsMissingPlugins = 0 + var usedPlugins = 0 + var unusedPlugins = 0 func count(for format: PluginFormat) -> Int { formatCounts[format, default: 0] } } @@ -54,6 +62,8 @@ struct DashboardView: View { @Environment(AppState.self) private var appState @Environment(\.modelContext) private var modelContext @Query(filter: #Predicate { !$0.isRemoved }) private var plugins: [Plugin] + @Query(filter: #Predicate { !$0.isRemoved }) + private var abletonProjects: [AbletonProject] @State private var sidebarSelection: SidebarFilter = .all @State private var searchText = "" @State private var debouncedSearchText = "" @@ -61,6 +71,7 @@ struct DashboardView: View { @State private var sortOrder = [KeyPathComparator(\PluginRow.name)] @State private var selectedPluginIDs: Set = [] @State private var showInspector = false + @State private var selectedProjectForDetail: AbletonProject? // MARK: - Computed helpers @@ -80,6 +91,26 @@ struct DashboardView: View { } } } + + // Project counts + c.totalProjects = abletonProjects.count + var usedPluginNames: Set = [] + for project in abletonProjects { + if project.missingPluginCount > 0 { + c.projectsMissingPlugins += 1 + } + for pp in project.plugins where pp.isInstalled { + usedPluginNames.insert(pp.pluginName.lowercased()) + } + } + for plugin in plugins where !plugin.isHidden && !plugin.isRemoved { + if usedPluginNames.contains(plugin.name.lowercased()) { + c.usedPlugins += 1 + } else { + c.unusedPlugins += 1 + } + } + return c } @@ -104,6 +135,14 @@ struct DashboardView: View { } case .hidden: break + case .usedPlugins: + let usedNames = collectUsedPluginNames() + result = result.filter { usedNames.contains($0.name.lowercased()) } + case .unusedPlugins: + let usedNames = collectUsedPluginNames() + result = result.filter { !usedNames.contains($0.name.lowercased()) } + case .allProjects, .projectsMissingPlugins: + break } } @@ -210,6 +249,16 @@ struct DashboardView: View { try? modelContext.save() } + private func collectUsedPluginNames() -> Set { + var names: Set = [] + for project in abletonProjects { + for pp in project.plugins where pp.isInstalled { + names.insert(pp.pluginName.lowercased()) + } + } + return names + } + // MARK: - Body var body: some View { @@ -237,6 +286,26 @@ struct DashboardView: View { Label("Hidden (\(counts.hidden))", systemImage: "eye.slash") .tag(SidebarFilter.hidden) } + if counts.totalProjects > 0 || appState.isProjectScanning || !appState.projectScanDirectories().isEmpty { + Section("Projects") { + Label("All Projects (\(counts.totalProjects))", systemImage: "doc.text") + .tag(SidebarFilter.allProjects) + if counts.projectsMissingPlugins > 0 { + Label( + "Missing Plugins (\(counts.projectsMissingPlugins))", + systemImage: "exclamationmark.triangle" + ) + .tag(SidebarFilter.projectsMissingPlugins) + .foregroundStyle(.red) + } + } + Section("Usage") { + Label("Used (\(counts.usedPlugins))", systemImage: "checkmark.circle") + .tag(SidebarFilter.usedPlugins) + Label("Unused (\(counts.unusedPlugins))", systemImage: "circle.dashed") + .tag(SidebarFilter.unusedPlugins) + } + } } .navigationTitle("Plugins") .safeAreaInset(edge: .bottom) { @@ -247,180 +316,245 @@ struct DashboardView: View { .padding(16) } } detail: { - Table(rows, selection: $selectedPluginIDs, sortOrder: $sortOrder) { - TableColumn("Name", value: \PluginRow.name) { (row: PluginRow) in - Text(row.name) - } - TableColumn("Vendor", value: \PluginRow.vendorName) { (row: PluginRow) in - Text(row.vendorName) - } - TableColumn("Format", value: \PluginRow.formatRawValue) { (row: PluginRow) in - PluginFormatBadge(format: row.plugin.format) - } - .width(min: 50, ideal: 60, max: 80) - TableColumn("Installed", value: \PluginRow.currentVersion) { (row: PluginRow) in - Text(row.currentVersion) - .monospacedDigit() + switch sidebarSelection { + case .allProjects: + projectListDetail(projects: abletonProjects) + case .projectsMissingPlugins: + projectListDetail( + projects: abletonProjects.filter { $0.missingPluginCount > 0 } + ) + default: + pluginTableDetail(rows: rows, manifest: manifest) + } + } + .frame(minWidth: 700, minHeight: 400) + } + + // MARK: - Project Detail + + @ViewBuilder + private func projectListDetail(projects: [AbletonProject]) -> some View { + ProjectListView( + projects: projects, + searchText: searchText, + onSelectProject: { selectedProjectForDetail = $0 } + ) + .safeAreaInset(edge: .top) { + if appState.isProjectScanning { + ProgressView(value: appState.projectScanProgress) + .progressViewStyle(.linear) + } + } + .sheet(item: $selectedProjectForDetail) { project in + ProjectDetailView(project: project) + .frame(minWidth: 500, minHeight: 400) + } + .toolbar { + ToolbarItem(placement: .principal) { + if appState.isProjectScanning { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(appState.projectScanStatusText.isEmpty ? "Scanning..." : appState.projectScanStatusText) + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Button { + Task { await appState.performProjectScan() } + } label: { + HStack(spacing: 6) { + Text("Scan Projects") + .font(.caption) + Image(systemName: "arrow.clockwise") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } } - .width(min: 60, ideal: 80, max: 120) - TableColumn("Available", value: \PluginRow.updatePriority) { (row: PluginRow) in - AvailableVersionCell( - availableVersion: row.availableVersion, - hasUpdate: row.hasUpdate - ) + } + ToolbarItem(placement: .primaryAction) { + HStack(spacing: 8) { + TextField("Search projects", text: $searchText) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 180, idealWidth: 250) } - .width(min: 60, ideal: 80, max: 120) - TableColumn("Download", value: \PluginRow.hasDownload) { (row: PluginRow) in - if let urlString = row.downloadURL, let url = URL(string: urlString) { - Link(destination: url) { - Label("Get", systemImage: "arrow.down.circle") - .labelStyle(.titleAndIcon) - } - .buttonStyle(.borderless) + } + } + } + + // MARK: - Plugin Table Detail + + @ViewBuilder + private func pluginTableDetail(rows: [PluginRow], manifest: [String: UpdateManifestEntry]) -> some View { + Table(rows, selection: $selectedPluginIDs, sortOrder: $sortOrder) { + TableColumn("Name", value: \PluginRow.name) { (row: PluginRow) in + Text(row.name) + } + TableColumn("Vendor", value: \PluginRow.vendorName) { (row: PluginRow) in + Text(row.vendorName) + } + TableColumn("Format", value: \PluginRow.formatRawValue) { (row: PluginRow) in + PluginFormatBadge(format: row.plugin.format) + } + .width(min: 50, ideal: 60, max: 80) + TableColumn("Installed", value: \PluginRow.currentVersion) { (row: PluginRow) in + Text(row.currentVersion) + .monospacedDigit() + } + .width(min: 60, ideal: 80, max: 120) + TableColumn("Available", value: \PluginRow.updatePriority) { (row: PluginRow) in + AvailableVersionCell( + availableVersion: row.availableVersion, + hasUpdate: row.hasUpdate + ) + } + .width(min: 60, ideal: 80, max: 120) + TableColumn("Download", value: \PluginRow.hasDownload) { (row: PluginRow) in + if let urlString = row.downloadURL, let url = URL(string: urlString) { + Link(destination: url) { + Label("Get", systemImage: "arrow.down.circle") + .labelStyle(.titleAndIcon) } + .buttonStyle(.borderless) } - .width(min: 50, ideal: 70, max: 90) - TableColumn("Architecture", value: \PluginRow.architectureDisplay) { (row: PluginRow) in - HStack(spacing: 4) { - Text(row.architectureDisplay) - if row.plugin.isLegacyArchitecture { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - .help("Legacy architecture — may not run natively") - } + } + .width(min: 50, ideal: 70, max: 90) + TableColumn("Architecture", value: \PluginRow.architectureDisplay) { (row: PluginRow) in + HStack(spacing: 4) { + Text(row.architectureDisplay) + if row.plugin.isLegacyArchitecture { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .help("Legacy architecture — may not run natively") } } - .width(min: 80, ideal: 100, max: 140) - TableColumn("Size", value: \PluginRow.fileSize) { (row: PluginRow) in - Text(row.fileSizeDisplay) - .monospacedDigit() + } + .width(min: 80, ideal: 100, max: 140) + TableColumn("Size", value: \PluginRow.fileSize) { (row: PluginRow) in + Text(row.fileSizeDisplay) + .monospacedDigit() + } + .width(min: 50, ideal: 65, max: 90) + TableColumn("Date Added", value: \PluginRow.dateAdded) { (row: PluginRow) in + Text(row.dateAdded.formatted(.dateTime.month(.abbreviated).day().year())) + } + .width(min: 80, ideal: 100, max: 130) + } + .background(NSTableViewFinder.enableColumnAutoResize()) + .id(sidebarSelection) + .contextMenu(forSelectionType: PersistentIdentifier.self) { ids in + if !ids.isEmpty { + let count = ids.count + Button("Copy Path\(count > 1 ? "s" : "")") { + copyPaths(for: ids) } - .width(min: 50, ideal: 65, max: 90) - TableColumn("Date Added", value: \PluginRow.dateAdded) { (row: PluginRow) in - Text(row.dateAdded.formatted(.dateTime.month(.abbreviated).day().year())) + Button("Copy Full Details") { + copyFullDetails(for: ids, manifest: manifest) } - .width(min: 80, ideal: 100, max: 130) - } - .background(NSTableViewFinder.enableColumnAutoResize()) - // Force SwiftUI to recreate the Table entirely when the filter - // changes, instead of diffing hundreds of row insertions/removals - // which causes a multi-second hang on the main thread. - .id(sidebarSelection) - .contextMenu(forSelectionType: PersistentIdentifier.self) { ids in - if !ids.isEmpty { - let count = ids.count - Button("Copy Path\(count > 1 ? "s" : "")") { - copyPaths(for: ids) - } - Button("Copy Full Details") { - copyFullDetails(for: ids, manifest: manifest) - } - Divider() + Divider() - Button("Reveal in Finder") { - revealInFinder(ids: ids) - } - Button("Open Publisher Website") { - openVendorWebsites(for: ids, manifest: manifest) - } + Button("Reveal in Finder") { + revealInFinder(ids: ids) + } + Button("Open Publisher Website") { + openVendorWebsites(for: ids, manifest: manifest) + } - Divider() + 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) - } + 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) } } } - .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.")) - } + } + .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.")) } - .safeAreaInset(edge: .bottom) { - HStack { - Text(statusBarText(rowCount: rows.count)) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - .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) + } + .safeAreaInset(edge: .bottom) { + HStack { + Text(statusBarText(rowCount: rows.count)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .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 { - showInspector.toggle() - } label: { - Label("Info", systemImage: "sidebar.trailing") - } - .labelStyle(.titleAndIcon) + } + ToolbarItem(placement: .primaryAction) { + HStack(spacing: 8) { + TextField("Search plugins or vendors", text: $searchText) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 180, idealWidth: 250) + Button { + showInspector.toggle() + } label: { + Label("Info", systemImage: "sidebar.trailing") } + .labelStyle(.titleAndIcon) } } - .inspector(isPresented: $showInspector) { - if let plugin = selectedPlugin { - PluginDetailView(plugin: plugin, manifest: appState.manifestEntries) - .inspectorColumnWidth(min: 280, ideal: 320, max: 400) - } else { - ContentUnavailableView("No Selection", systemImage: "cursorarrow.click", description: Text("Select a plugin to view its details.")) - .inspectorColumnWidth(min: 280, ideal: 320, max: 400) - } + } + .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) } - .onChange(of: searchText) { _, newValue in - searchTask?.cancel() - if newValue.isEmpty { - // Clear immediately — no point debouncing an empty string - debouncedSearchText = "" - } else { - searchTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) - if !Task.isCancelled { - debouncedSearchText = newValue - } + } + .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 } } } - .onChange(of: selectedPluginIDs) { _, newValue in - if !newValue.isEmpty { - showInspector = true - } + } + .onChange(of: selectedPluginIDs) { _, newValue in + if !newValue.isEmpty { + showInspector = true } } - .frame(minWidth: 700, minHeight: 400) } } diff --git a/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift b/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift index 70e15f0..16f2f8c 100644 --- a/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift +++ b/PluginUpdater/PluginUpdater/Views/MenuBar/MenuBarPopoverView.swift @@ -89,7 +89,11 @@ struct MenuBarPopoverView: View { } Button("Open Logs Folder") { - NSWorkspace.shared.open(AppLogger.shared.logsDirectoryURL) + let url = AppLogger.shared.logsDirectoryURL + NSWorkspace.shared.open( + url, + configuration: NSWorkspace.OpenConfiguration() + ) { _, _ in } } .buttonStyle(.plain) .font(.caption) diff --git a/PluginUpdater/PluginUpdater/Views/Projects/ProjectDetailView.swift b/PluginUpdater/PluginUpdater/Views/Projects/ProjectDetailView.swift new file mode 100644 index 0000000..1d7947b --- /dev/null +++ b/PluginUpdater/PluginUpdater/Views/Projects/ProjectDetailView.swift @@ -0,0 +1,175 @@ +import SwiftUI +import SwiftData + +@MainActor +struct ProjectDetailView: View { + @Environment(\.dismiss) private var dismiss + let project: AbletonProject + + private var auPlugins: [AbletonProjectPlugin] { + project.plugins.filter { $0.pluginType == "au" } + .sorted { $0.pluginName.localizedCompare($1.pluginName) == .orderedAscending } + } + + private var vst3Plugins: [AbletonProjectPlugin] { + project.plugins.filter { $0.pluginType == "vst3" } + .sorted { $0.pluginName.localizedCompare($1.pluginName) == .orderedAscending } + } + + private var vst2Plugins: [AbletonProjectPlugin] { + project.plugins.filter { $0.pluginType == "vst2" } + .sorted { $0.pluginName.localizedCompare($1.pluginName) == .orderedAscending } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Header + VStack(alignment: .leading, spacing: 4) { + Text(project.name) + .font(.title2) + .fontWeight(.semibold) + + HStack(spacing: 16) { + if let version = project.abletonVersion { + Label(version, systemImage: "music.note") + .font(.caption) + } + Label( + project.lastModified.formatted( + .dateTime.month(.abbreviated).day().year() + ), + systemImage: "calendar" + ) + .font(.caption) + Label( + ByteCountFormatter.string( + fromByteCount: project.fileSize, + countStyle: .file + ), + systemImage: "doc" + ) + .font(.caption) + } + .foregroundStyle(.secondary) + + Text(project.filePath) + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + // Summary badges + HStack(spacing: 12) { + StatBadge( + title: "Total Plugins", + count: project.plugins.count, + systemImage: "puzzlepiece.extension", + color: .secondary + ) + StatBadge( + title: "Installed", + count: project.installedPluginCount, + systemImage: "checkmark.circle.fill", + color: .green + ) + StatBadge( + title: "Missing", + count: project.missingPluginCount, + systemImage: "exclamationmark.triangle.fill", + color: project.missingPluginCount > 0 ? .red : .secondary + ) + } + + Divider() + + // Plugin sections + if !auPlugins.isEmpty { + pluginSection(title: "AU Plugins", plugins: auPlugins) + } + if !vst3Plugins.isEmpty { + pluginSection(title: "VST3 Plugins", plugins: vst3Plugins) + } + if !vst2Plugins.isEmpty { + pluginSection(title: "VST2 Plugins", plugins: vst2Plugins) + } + + if project.plugins.isEmpty { + ContentUnavailableView( + "No Plugins Found", + systemImage: "puzzlepiece.extension", + description: Text("This project does not use any third-party plugins.") + ) + } + } + .padding() + } + .safeAreaInset(edge: .bottom) { + HStack { + Spacer() + Button("OK") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding() + } + } + + private func pluginSection(title: String, plugins sectionPlugins: [AbletonProjectPlugin]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + + ForEach(Array(sectionPlugins.enumerated()), id: \.offset) { _, plugin in + let installed = plugin.isInstalled + HStack { + Image(systemName: installed + ? "checkmark.circle.fill" + : "xmark.circle.fill") + .foregroundStyle(installed ? .green : .red) + + VStack(alignment: .leading, spacing: 1) { + Text(plugin.pluginName) + .fontWeight(.medium) + if let vendor = plugin.vendorName, !vendor.isEmpty { + Text(vendor) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Text(installed ? "Installed" : "Not Installed") + .font(.caption) + .foregroundStyle(installed ? Color.secondary : Color.red) + } + .padding(.vertical, 2) + } + } + } +} + +private struct StatBadge: View { + let title: String + let count: Int + let systemImage: String + let color: Color + + var body: some View { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(color) + Text("\(count)") + .font(.title3) + .fontWeight(.semibold) + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(minWidth: 80) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/PluginUpdater/PluginUpdater/Views/Projects/ProjectListView.swift b/PluginUpdater/PluginUpdater/Views/Projects/ProjectListView.swift new file mode 100644 index 0000000..2955ed4 --- /dev/null +++ b/PluginUpdater/PluginUpdater/Views/Projects/ProjectListView.swift @@ -0,0 +1,152 @@ +import SwiftUI +import SwiftData +import AppKit + +/// Sortable row wrapper for AbletonProject, matching the PluginRow pattern used in DashboardView. +struct ProjectRow: Identifiable { + let project: AbletonProject + var id: PersistentIdentifier { project.id } + var name: String { project.name } + var abletonVersion: String { project.abletonVersion ?? "" } + var pluginCount: Int { project.plugins.count } + var missingCount: Int { project.missingPluginCount } + var lastModified: Date { project.lastModified } + var fileSize: Int64 { project.fileSize } + var filePath: String { project.filePath } +} + +@MainActor +struct ProjectListView: View { + let projects: [AbletonProject] + let searchText: String + let onSelectProject: (AbletonProject) -> Void + + @State private var sortOrder: [KeyPathComparator] = [ + KeyPathComparator(\ProjectRow.name) + ] + @State private var selectedProjectIDs: Set = [] + + private var filteredRows: [ProjectRow] { + var result = projects + if !searchText.isEmpty { + result = result.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } + let rows = result.map { ProjectRow(project: $0) } + return rows.sorted(using: sortOrder) + } + + var body: some View { + let rows = filteredRows + + VStack(spacing: 0) { + Table(rows, selection: $selectedProjectIDs, sortOrder: $sortOrder) { + TableColumn("Name", value: \ProjectRow.name) { row in + Text(row.name) + .fontWeight(.medium) + } + TableColumn("Ableton Version", value: \ProjectRow.abletonVersion) { row in + Text(row.abletonVersion) + .foregroundStyle(.secondary) + } + .width(ideal: 120) + TableColumn("Plugins", value: \ProjectRow.pluginCount) { row in + Label("\(row.pluginCount)", systemImage: "puzzlepiece.extension") + .monospacedDigit() + } + .width(ideal: 65) + TableColumn("Missing", value: \ProjectRow.missingCount) { row in + if row.missingCount > 0 { + Label("\(row.missingCount)", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .monospacedDigit() + } else { + Text("0") + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .width(ideal: 65) + TableColumn("Last Modified", value: \ProjectRow.lastModified) { row in + Text(row.lastModified.formatted( + .dateTime.month(.abbreviated).day().year() + )) + } + .width(ideal: 110) + TableColumn("Size", value: \ProjectRow.fileSize) { row in + Text(ByteCountFormatter.string( + fromByteCount: row.fileSize, + countStyle: .file + )) + .monospacedDigit() + } + .width(ideal: 70) + TableColumn("Path", value: \ProjectRow.filePath) { row in + Text(row.filePath) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .help(row.filePath) + } + .width(ideal: 200) + } + .contextMenu(forSelectionType: PersistentIdentifier.self) { ids in + if !ids.isEmpty { + Button("Reveal in Finder") { + revealInFinder(ids: ids) + } + Button("Copy Path\(ids.count > 1 ? "s" : "")") { + copyPaths(ids: ids) + } + } + } primaryAction: { ids in + if let id = ids.first, + let project = projects.first(where: { $0.id == id }) { + onSelectProject(project) + } + } + .overlay { + if rows.isEmpty && !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } else if projects.isEmpty { + ContentUnavailableView( + "No Projects Scanned", + systemImage: "doc.text.magnifyingglass", + description: Text( + "Configure Ableton project folders in Settings and scan to see plugin usage." + ) + ) + } + } + + HStack { + Text("\(rows.count) project\(rows.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.bar) + } + } + + // MARK: - Actions + + private func revealInFinder(ids: Set) { + let urls = projects + .filter { ids.contains($0.id) } + .map { URL(fileURLWithPath: $0.filePath) } + NSWorkspace.shared.activateFileViewerSelecting(urls) + } + + private func copyPaths(ids: Set) { + let paths = projects + .filter { ids.contains($0.id) } + .map(\.filePath) + .joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(paths, forType: .string) + } +} diff --git a/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift b/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift index 99e0b63..e33c71a 100644 --- a/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift +++ b/PluginUpdater/PluginUpdater/Views/Settings/SettingsView.swift @@ -27,6 +27,10 @@ struct SettingsView: View { ScanPathsEditor() .tabItem { Label("Scan Paths", systemImage: "folder.badge.gearshape") } + // Projects + ProjectScanSettings() + .tabItem { Label("Projects", systemImage: "doc.text") } + // Notifications NotificationSettingsView() .tabItem { Label("Notifications", systemImage: "bell.badge") } @@ -126,9 +130,94 @@ struct SettingsView: View { .tabItem { Label("General", systemImage: "gearshape") } } .padding() - .frame(width: 500, height: 400) + .frame(width: 500, height: 450) .onAppear { launchAtLogin = SMAppService.mainApp.status == .enabled } } } + +private struct ProjectScanSettings: View { + @Environment(AppState.self) private var appState + @AppStorage(Constants.UserDefaultsKeys.scanProjectsOnLaunch) + private var scanProjectsOnLaunch = false + @AppStorage(Constants.UserDefaultsKeys.monitorProjectDirectories) + private var monitorProjectDirectories = false + @State private var projectPaths: [String] = [] + + var body: some View { + Form { + Section("Ableton Project Folders") { + ForEach(projectPaths, id: \.self) { path in + HStack { + Image(systemName: "folder") + .foregroundStyle(.secondary) + Text(path) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(role: .destructive) { + projectPaths.removeAll { $0 == path } + saveProjectPaths() + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + } + + Button("Add Folder…") { + chooseFolder() + } + } + + Section("Options") { + Toggle("Scan projects on app launch", isOn: $scanProjectsOnLaunch) + Toggle("Monitor project folders for changes", isOn: $monitorProjectDirectories) + .onChange(of: monitorProjectDirectories) { _, enabled in + if enabled { + appState.startProjectMonitoring() + } else { + appState.stopProjectMonitoring() + } + } + } + + Section { + Button("Scan Projects Now") { + Task { await appState.performProjectScan() } + } + .disabled(appState.isProjectScanning) + } + } + .onAppear { + projectPaths = UserDefaults.standard.stringArray( + forKey: Constants.UserDefaultsKeys.projectScanDirectories + ) ?? Constants.defaultProjectScanDirectories + } + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.message = "Choose a folder containing Ableton Live projects" + panel.prompt = "Add" + + guard panel.runModal() == .OK, let url = panel.url else { return } + let path = url.path(percentEncoded: false) + if !projectPaths.contains(path) { + projectPaths.append(path) + saveProjectPaths() + } + } + + private func saveProjectPaths() { + UserDefaults.standard.set( + projectPaths, + forKey: Constants.UserDefaultsKeys.projectScanDirectories + ) + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Models/AbletonProjectModelTests.swift b/PluginUpdater/PluginUpdaterTests/Models/AbletonProjectModelTests.swift new file mode 100644 index 0000000..e5f4ab5 --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Models/AbletonProjectModelTests.swift @@ -0,0 +1,70 @@ +import Testing +import Foundation +import SwiftData +@testable import PluginUpdater + +@Suite("AbletonProject Model Tests") +struct AbletonProjectModelTests { + + private func makeContainer() throws -> ModelContainer { + try PersistenceController.makeContainer(inMemory: true) + } + + @Test("Computes installed plugin count") + func computesInstalledPluginCount() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/test/project.als", + name: "Test", + lastModified: .now, + fileSize: 1024 + ) + context.insert(project) + project.plugins.append(AbletonProjectPlugin(pluginName: "A", pluginType: "au", isInstalled: true)) + project.plugins.append(AbletonProjectPlugin(pluginName: "B", pluginType: "vst3", isInstalled: true)) + project.plugins.append(AbletonProjectPlugin(pluginName: "C", pluginType: "vst2", isInstalled: false)) + try context.save() + #expect(project.installedPluginCount == 2) + } + + @Test("Computes missing plugin count") + func computesMissingPluginCount() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/test/project.als", + name: "Test", + lastModified: .now, + fileSize: 1024 + ) + context.insert(project) + project.plugins.append(AbletonProjectPlugin(pluginName: "A", pluginType: "au", isInstalled: true)) + project.plugins.append(AbletonProjectPlugin(pluginName: "B", pluginType: "vst3", isInstalled: true)) + project.plugins.append(AbletonProjectPlugin(pluginName: "C", pluginType: "vst2", isInstalled: false)) + try context.save() + #expect(project.missingPluginCount == 1) + } + + @Test("Cascade deletes plugins when project is deleted") + func cascadeDeletesPlugins() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/test/project.als", + name: "Test", + lastModified: .now, + fileSize: 1024 + ) + context.insert(project) + project.plugins.append(AbletonProjectPlugin(pluginName: "A", pluginType: "au")) + project.plugins.append(AbletonProjectPlugin(pluginName: "B", pluginType: "vst3")) + try context.save() + + context.delete(project) + try context.save() + + let remainingPlugins = try context.fetch(FetchDescriptor()) + #expect(remainingPlugins.isEmpty) + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Services/AbletonProjectParserTests.swift b/PluginUpdater/PluginUpdaterTests/Services/AbletonProjectParserTests.swift new file mode 100644 index 0000000..bfef3c3 --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Services/AbletonProjectParserTests.swift @@ -0,0 +1,369 @@ +import Testing +import Foundation +@testable import PluginUpdater + +@Suite("AbletonProjectParser Tests") +struct AbletonProjectParserTests { + + /// Creates a minimal gzip-compressed .als fixture from raw XML. + private func makeALSFixture(xml: String) throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("ALSTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let alsURL = tempDir.appendingPathComponent("test.als") + let inputURL = tempDir.appendingPathComponent("test.xml") + try xml.data(using: .utf8)!.write(to: inputURL) + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") + proc.arguments = ["-c", inputURL.path] + let pipe = Pipe() + proc.standardOutput = pipe + try proc.run() + let gzData = pipe.fileHandleForReading.readDataToEndOfFile() + proc.waitUntilExit() + try gzData.write(to: alsURL) + return alsURL + } + + private func cleanup(_ url: URL) { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + + @Test("Parses Ableton version from root element") + func parsesAbletonVersion() async throws { + let xml = """ + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.abletonVersion == "Ableton Live 12.1.5") + #expect(project.plugins.isEmpty) + } + + @Test("Parses AU plugin with component codes") + func parsesAUPlugin() async throws { + // ComponentType 'aufx' = 1635083896, SubType 'PrL2' = 1349667890, Manufacturer 'FbFl' = 1180845676 + let xml = """ + + + + + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + let plugin = project.plugins[0] + #expect(plugin.pluginName == "Pro-L 2") + #expect(plugin.pluginType == "au") + #expect(plugin.vendorName == "FabFilter") + #expect(plugin.auComponentType == "aufx") + #expect(plugin.auComponentSubType == "PrL2") + #expect(plugin.auComponentManufacturer == "FbFl") + } + + @Test("AU parser ignores nested preset Name elements") + func parsesAUPluginIgnoresNestedPreset() async throws { + let xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + #expect(project.plugins[0].pluginName == "Real Name") + } + + @Test("Parses VST3 plugin with TUID") + func parsesVST3Plugin() async throws { + let xml = """ + + + + + + + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + let plugin = project.plugins[0] + #expect(plugin.pluginName == "Serum") + #expect(plugin.pluginType == "vst3") + #expect(plugin.vst3TUID != nil) + } + + @Test("VST3 parser ignores nested preset Name elements") + func parsesVST3PluginIgnoresNestedPreset() async throws { + let xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + #expect(project.plugins[0].pluginName == "Real VST3 Name") + } + + @Test("Parses VST3 plugin with negative TUID fields") + func parsesVST3PluginWithNegativeTUID() async throws { + let xml = """ + + + + + + + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + // -1 as UInt32 = 0xFFFFFFFF, -2147483648 = 0x80000000 + #expect(project.plugins[0].vst3TUID == "FFFFFFFF000000008000000000000001") + } + + @Test("Parses VST2 plugin") + func parsesVST2Plugin() async throws { + let xml = """ + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + #expect(project.plugins[0].pluginName == "Sylenth1") + #expect(project.plugins[0].pluginType == "vst2") + } + + @Test("Deduplicates plugins within a project") + func deduplicatesPluginsWithinProject() async throws { + let xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.count == 1) + } + + @Test("Parses empty project with no plugins") + func parsesEmptyProject() async throws { + let xml = """ + + + + + + + """ + let url = try makeALSFixture(xml: xml) + defer { cleanup(url) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: url) + #expect(project.plugins.isEmpty) + #expect(project.abletonVersion == "Ableton Live 12.1.5") + } + + @Test("Parses plain (non-gzipped) XML input") + func parsesPlainXML() async throws { + let xml = """ + + + + + + + + + + + + + + + + + """ + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("ALSTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let alsURL = tempDir.appendingPathComponent("test.als") + try xml.data(using: .utf8)!.write(to: alsURL) + defer { try? FileManager.default.removeItem(at: tempDir) } + let parser = AbletonProjectParser() + let project = try await parser.parse(fileURL: alsURL) + #expect(project.plugins.count == 1) + #expect(project.plugins[0].pluginName == "TestPlug") + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Services/AbletonProjectScannerTests.swift b/PluginUpdater/PluginUpdaterTests/Services/AbletonProjectScannerTests.swift new file mode 100644 index 0000000..700d982 --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Services/AbletonProjectScannerTests.swift @@ -0,0 +1,197 @@ +import Testing +import Foundation +@testable import PluginUpdater + +@Suite("AbletonProjectScanner Tests") +struct AbletonProjectScannerTests { + + private func makeTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ScannerTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func cleanup(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } + + private func createFile(at dir: URL, name: String, size: Int) throws { + let data = Data(repeating: 0, count: size) + try data.write(to: dir.appendingPathComponent(name)) + } + + @Test("discoverALSFiles finds all .als files recursively") + func discoverALSFilesFindsAllFiles() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + let subdir = dir.appendingPathComponent("SubProject") + try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true) + + try createFile(at: dir, name: "project1.als", size: 100) + try createFile(at: dir, name: "project2.als", size: 200) + try createFile(at: subdir, name: "project3.als", size: 300) + + let scanner = AbletonProjectScanner() + let files = scanner.discoverALSFiles(in: dir) + #expect(files.count == 3) + } + + @Test("discoverALSFiles ignores non-.als files") + func discoverALSFilesIgnoresNonALSFiles() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + try createFile(at: dir, name: "readme.txt", size: 50) + try createFile(at: dir, name: "audio.wav", size: 100) + try createFile(at: dir, name: "project.als", size: 150) + + let scanner = AbletonProjectScanner() + let files = scanner.discoverALSFiles(in: dir) + #expect(files.count == 1) + #expect(files.first?.lastPathComponent == "project.als") + } + + @Test("discoverALSFiles skips hidden files and directories") + func discoverALSFilesSkipsHiddenFiles() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + try createFile(at: dir, name: ".hidden.als", size: 100) + try createFile(at: dir, name: "visible.als", size: 100) + let hiddenDir = dir.appendingPathComponent(".hidden_folder") + try FileManager.default.createDirectory(at: hiddenDir, withIntermediateDirectories: true) + try createFile(at: hiddenDir, name: "inside.als", size: 100) + + let scanner = AbletonProjectScanner() + let files = scanner.discoverALSFiles(in: dir) + #expect(files.count == 1) + #expect(files.first?.lastPathComponent == "visible.als") + } + + @Test("discoverALSFiles returns empty for missing directory") + func discoverALSFilesReturnsEmptyForMissingDirectory() { + let scanner = AbletonProjectScanner() + let files = scanner.discoverALSFiles(in: URL(fileURLWithPath: "/nonexistent/path/\(UUID().uuidString)")) + #expect(files.isEmpty) + } + + @Test("discoverALSFiles returns files sorted by size ascending") + func discoverALSFilesSortedBySize() throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + try createFile(at: dir, name: "large.als", size: 10000) + try createFile(at: dir, name: "small.als", size: 100) + try createFile(at: dir, name: "medium.als", size: 1000) + + let scanner = AbletonProjectScanner() + let files = scanner.discoverALSFiles(in: dir) + #expect(files.count == 3) + + let sizes = files.compactMap { + (try? $0.resourceValues(forKeys: [.fileSizeKey]))?.fileSize + } + #expect(sizes == sizes.sorted()) + } + + // MARK: - Streaming Scan Tests + + @Test("scanStreaming yields batches and completed event") + func scanStreamingYieldsBatchesAndCompleted() async throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + // Create dummy .als files (these won't parse as valid gzip XML, so we expect errors) + for i in 0..<5 { + try createFile(at: dir, name: "project\(i).als", size: 100 + i * 50) + } + + let scanner = AbletonProjectScanner() + let stream = await scanner.scanStreaming(directories: [dir], batchSize: 2) + + var progressEvents: [AbletonProjectScanner.ScanProgress] = [] + var errorCount = 0 + var completedDuration: TimeInterval? + + for await event in stream { + switch event { + case .progress(let p): + progressEvents.append(p) + case .batch: + break // May not get batches since files aren't valid gzip + case .error: + errorCount += 1 + case .completed(let duration): + completedDuration = duration + } + } + + // Should always get a completed event + #expect(completedDuration != nil) + // Should get at least a discovering progress event + let hasDiscovering = progressEvents.contains { + if case .discovering = $0 { return true } + return false + } + #expect(hasDiscovering) + // All 5 files should produce either a batch or error + #expect(errorCount + progressEvents.filter { + if case .parsing = $0 { return true } + return false + }.count >= 5) + } + + @Test("scanStreaming with empty directory yields completed with no batches") + func scanStreamingEmptyDirectory() async throws { + let dir = try makeTempDir() + defer { cleanup(dir) } + + let scanner = AbletonProjectScanner() + let stream = await scanner.scanStreaming(directories: [dir], batchSize: 5) + + var batchCount = 0 + var completedDuration: TimeInterval? + + for await event in stream { + switch event { + case .batch: + batchCount += 1 + case .completed(let duration): + completedDuration = duration + default: + break + } + } + + #expect(batchCount == 0) + #expect(completedDuration != nil) + } + + @Test("scanStreaming with nonexistent directory yields completed without errors") + func scanStreamingNonexistentDirectory() async throws { + let scanner = AbletonProjectScanner() + let stream = await scanner.scanStreaming( + directories: [URL(fileURLWithPath: "/nonexistent/\(UUID().uuidString)")], + batchSize: 5 + ) + + var errorCount = 0 + var completedDuration: TimeInterval? + + for await event in stream { + switch event { + case .error: + errorCount += 1 + case .completed(let duration): + completedDuration = duration + default: + break + } + } + + // No files discovered → no parse errors, just a completed event + #expect(errorCount == 0) + #expect(completedDuration != nil) + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Services/PluginMatcherTests.swift b/PluginUpdater/PluginUpdaterTests/Services/PluginMatcherTests.swift new file mode 100644 index 0000000..905881d --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Services/PluginMatcherTests.swift @@ -0,0 +1,311 @@ +import Testing +import Foundation +import SwiftData +@testable import PluginUpdater + +@Suite("PluginMatcher Tests") +struct PluginMatcherTests { + + private func makeContainer() throws -> ModelContainer { + try PersistenceController.makeContainer(inMemory: true) + } + + private func insertPlugin( + name: String, + format: PluginFormat, + vendor: String = "TestVendor", + into context: ModelContext + ) -> Plugin { + let plugin = Plugin( + name: name, + bundleIdentifier: "com.test.\(name.lowercased().replacingOccurrences(of: " ", with: "-"))", + format: format, + currentVersion: "1.0.0", + path: "/Library/Audio/Plug-Ins/\(format.fileExtension)/\(name).\(format.fileExtension)", + vendorName: vendor + ) + context.insert(plugin) + return plugin + } + + private func makeParsed( + name: String, + type: String, + vendor: String? = nil + ) -> AbletonProjectParser.ParsedPlugin { + AbletonProjectParser.ParsedPlugin( + pluginName: name, + pluginType: type, + auComponentType: nil, + auComponentSubType: nil, + auComponentManufacturer: nil, + vst3TUID: nil, + vendorName: vendor + ) + } + + private func fetchPlugins(from context: ModelContext) throws -> [Plugin] { + try context.fetch(FetchDescriptor()) + } + + @Test("Matches AU by exact name") + func matchesAUByExactName() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Pro-L 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Pro-L 2", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches VST3 by exact name") + func matchesVST3ByExactName() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Serum 2", format: .vst3, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Serum 2", type: "vst3"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches VST2 by exact name") + func matchesVST2ByExactName() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Duck", format: .vst2, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Duck", type: "vst2"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches case insensitive") + func matchesCaseInsensitive() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Pro-L 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "pro-l 2", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches cross-format") + func matchesCrossFormat() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Serum 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Serum 2", type: "vst3"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches by contains") + func matchesByContains() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "FabFilter Pro-L 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Pro-L 2", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches by reverse contains") + func matchesByReverseContains() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Pro-L 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "FabFilter Pro-L 2", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Returns not installed when no match") + func returnsNotInstalledWhenNoMatch() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Pro-L 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "NonExistentPlugin", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == false) + } + + // MARK: - Fuzzy Matching Tests + + @Test("Matches stripped version suffix") + func matchesStrippedVersionSuffix() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Phoscyon", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Phoscyon 2", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches normalized alphanumeric") + func matchesNormalizedAlphanumeric() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Filter Freak 1", format: .vst3, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "FilterFreak1", type: "vst3"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Matches vendor-scoped AU fuzzy") + func matchesVendorScopedFuzzy() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Phoscyon", format: .au, vendor: "D16", into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Phoscyon 2", type: "au", vendor: "D16"), + installedPlugins: plugins + ) + #expect(result.isInstalled == true) + } + + @Test("Does not fuzzy match unrelated plugin") + func doesNotFuzzyMatchUnrelatedPlugin() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Pro-Q 2", format: .au, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let result = PluginMatcher.match( + makeParsed(name: "Pro-L 2", type: "au"), + installedPlugins: plugins + ) + #expect(result.isInstalled == false) + } + + // MARK: - PluginIndex Tests + + @Test("PluginIndex produces same results as backward-compat overload") + func pluginIndexProducesSameResults() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Serum", format: .vst3, into: context) + let _ = insertPlugin(name: "Pro-L 2", format: .au, vendor: "FabFilter", into: context) + let _ = insertPlugin(name: "Filter Freak 1", format: .vst3, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let index = PluginMatcher.PluginIndex(plugins: plugins) + + let testCases: [AbletonProjectParser.ParsedPlugin] = [ + makeParsed(name: "Serum", type: "vst3"), + makeParsed(name: "Pro-L 2", type: "au"), + makeParsed(name: "FilterFreak1", type: "vst3"), + makeParsed(name: "NonExistent", type: "au"), + ] + + for parsed in testCases { + let linearResult = PluginMatcher.match(parsed, installedPlugins: plugins) + let indexResult = PluginMatcher.match(parsed, index: index) + #expect( + linearResult.isInstalled == indexResult.isInstalled, + "Mismatch for \(parsed.pluginName): linear=\(linearResult.isInstalled), index=\(indexResult.isInstalled)" + ) + } + } + + @Test("Match cache returns consistent results") + func matchCacheReturnsConsistentResults() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Serum", format: .vst3, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let index = PluginMatcher.PluginIndex(plugins: plugins) + + let parsed = makeParsed(name: "Serum", type: "vst3") + let first = PluginMatcher.match(parsed, index: index) + let second = PluginMatcher.match(parsed, index: index) + + #expect(first.isInstalled == second.isInstalled) + #expect(first.matchedPluginID == second.matchedPluginID) + } + + @Test("PluginIndex excludes removed plugins") + func pluginIndexExcludesRemovedPlugins() throws { + let container = try makeContainer() + let context = ModelContext(container) + let plugin = insertPlugin(name: "RemovedSynth", format: .vst3, into: context) + plugin.isRemoved = true + try context.save() + let plugins = try fetchPlugins(from: context) + let index = PluginMatcher.PluginIndex(plugins: plugins) + + let result = PluginMatcher.match( + makeParsed(name: "RemovedSynth", type: "vst3"), + index: index + ) + #expect(result.isInstalled == false) + } + + @Test("PluginIndex with empty plugin list returns no matches") + func pluginIndexEmptyReturnsNoMatches() throws { + let index = PluginMatcher.PluginIndex(plugins: []) + let result = PluginMatcher.match( + makeParsed(name: "Anything", type: "au"), + index: index + ) + #expect(result.isInstalled == false) + #expect(result.matchedPluginID == nil) + } + + @Test("PluginIndex handles unknown plugin type") + func pluginIndexUnknownType() throws { + let container = try makeContainer() + let context = ModelContext(container) + let _ = insertPlugin(name: "Serum", format: .vst3, into: context) + try context.save() + let plugins = try fetchPlugins(from: context) + let index = PluginMatcher.PluginIndex(plugins: plugins) + + let result = PluginMatcher.match( + makeParsed(name: "Serum", type: "unknown_format"), + index: index + ) + #expect(result.isInstalled == false) + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Services/ProjectReconcilerTests.swift b/PluginUpdater/PluginUpdaterTests/Services/ProjectReconcilerTests.swift new file mode 100644 index 0000000..7d02f16 --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Services/ProjectReconcilerTests.swift @@ -0,0 +1,333 @@ +import Testing +import Foundation +import SwiftData +@testable import PluginUpdater + +@Suite("ProjectReconciler Tests") +struct ProjectReconcilerTests { + + private func makeContainer() throws -> ModelContainer { + try PersistenceController.makeContainer(inMemory: true) + } + + private func makeParsedProject( + name: String, + filePath: String, + plugins: [AbletonProjectParser.ParsedPlugin] = [] + ) -> AbletonProjectParser.ParsedProject { + AbletonProjectParser.ParsedProject( + name: name, + filePath: filePath, + lastModified: .now, + fileSize: 1024, + abletonVersion: "Ableton Live 12.1.5", + plugins: plugins + ) + } + + private func makeParsedPlugin( + name: String, + type: String, + vendor: String? = nil + ) -> AbletonProjectParser.ParsedPlugin { + AbletonProjectParser.ParsedPlugin( + pluginName: name, + pluginType: type, + auComponentType: nil, + auComponentSubType: nil, + auComponentManufacturer: nil, + vst3TUID: nil, + vendorName: vendor + ) + } + + @Test("Inserts new projects") + func insertsNewProjects() async throws { + let container = try makeContainer() + let reconciler = ProjectReconciler(modelContainer: container) + let parsed = [ + makeParsedProject(name: "Track A", filePath: "/projects/track-a.als", plugins: [ + makeParsedPlugin(name: "Serum", type: "vst3"), + makeParsedPlugin(name: "Pro-L 2", type: "au"), + ]), + makeParsedProject(name: "Track B", filePath: "/projects/track-b.als", plugins: [ + makeParsedPlugin(name: "Sylenth1", type: "vst2"), + ]), + ] + let result = try await reconciler.reconcile(parsedProjects: parsed) + #expect(result.newProjects == 2) + #expect(result.updatedProjects == 0) + + let context = ModelContext(container) + let projects = try context.fetch(FetchDescriptor()) + #expect(projects.count == 2) + let trackA = projects.first { $0.name == "Track A" } + #expect(trackA?.plugins.count == 2) + } + + @Test("Updates existing project plugins") + func updatesExistingProject() async throws { + let container = try makeContainer() + + // Insert initial project + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/projects/track-a.als", + name: "Track A", + lastModified: .now, + fileSize: 1024 + ) + let oldPlugin = AbletonProjectPlugin(pluginName: "OldPlugin", pluginType: "vst3") + project.plugins.append(oldPlugin) + context.insert(project) + try context.save() + + // Re-scan with different plugins + let reconciler = ProjectReconciler(modelContainer: container) + let parsed = [ + makeParsedProject(name: "Track A", filePath: "/projects/track-a.als", plugins: [ + makeParsedPlugin(name: "NewPlugin", type: "au"), + ]), + ] + let result = try await reconciler.reconcile(parsedProjects: parsed) + #expect(result.updatedProjects == 1) + #expect(result.newProjects == 0) + + let freshContext = ModelContext(container) + let projects = try freshContext.fetch(FetchDescriptor()) + #expect(projects.count == 1) + #expect(projects[0].plugins.count == 1) + #expect(projects[0].plugins[0].pluginName == "NewPlugin") + } + + @Test("Marks removed projects on full scan") + func marksRemovedProjectsOnFullScan() async throws { + let container = try makeContainer() + + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/projects/gone.als", + name: "Gone", + lastModified: .now, + fileSize: 512 + ) + context.insert(project) + try context.save() + + let reconciler = ProjectReconciler(modelContainer: container) + let result = try await reconciler.reconcile(parsedProjects: [], fullScan: true) + #expect(result.removedProjects == 1) + + let freshContext = ModelContext(container) + let projects = try freshContext.fetch(FetchDescriptor()) + #expect(projects[0].isRemoved == true) + } + + @Test("Preserves projects on incremental scan") + func preservesProjectsOnIncrementalScan() async throws { + let container = try makeContainer() + + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/projects/kept.als", + name: "Kept", + lastModified: .now, + fileSize: 512 + ) + context.insert(project) + try context.save() + + let reconciler = ProjectReconciler(modelContainer: container) + let result = try await reconciler.reconcile(parsedProjects: [], fullScan: false) + #expect(result.removedProjects == 0) + + let freshContext = ModelContext(container) + let projects = try freshContext.fetch(FetchDescriptor()) + #expect(projects[0].isRemoved == false) + } + + @Test("Matches plugins against installed database") + func matchesPluginsAgainstInstalledDatabase() async throws { + let container = try makeContainer() + + // Pre-insert an installed plugin + let context = ModelContext(container) + let installed = Plugin( + name: "Pro-L 2", + bundleIdentifier: "com.fabfilter.ProL2", + format: .au, + currentVersion: "2.1.0", + path: "/Library/Audio/Plug-Ins/Components/Pro-L 2.component", + vendorName: "FabFilter" + ) + context.insert(installed) + try context.save() + + let reconciler = ProjectReconciler(modelContainer: container) + let parsed = [ + makeParsedProject(name: "TestProject", filePath: "/projects/test.als", plugins: [ + makeParsedPlugin(name: "Pro-L 2", type: "au", vendor: "FabFilter"), + ]), + ] + _ = try await reconciler.reconcile(parsedProjects: parsed) + + let freshContext = ModelContext(container) + let projects = try freshContext.fetch(FetchDescriptor()) + let projectPlugin = projects[0].plugins[0] + #expect(projectPlugin.isInstalled == true) + #expect(projectPlugin.matchedPluginID != nil) + } + + @Test("Sets not installed for missing plugins") + func setsNotInstalledForMissingPlugins() async throws { + let container = try makeContainer() + let reconciler = ProjectReconciler(modelContainer: container) + let parsed = [ + makeParsedProject(name: "TestProject", filePath: "/projects/test.als", plugins: [ + makeParsedPlugin(name: "NonExistent", type: "vst3"), + ]), + ] + _ = try await reconciler.reconcile(parsedProjects: parsed) + + let context = ModelContext(container) + let projects = try context.fetch(FetchDescriptor()) + #expect(projects[0].plugins[0].isInstalled == false) + } + + @Test("refreshPluginMatching updates flags after new plugin install") + func refreshPluginMatchingUpdatesFlags() async throws { + let container = try makeContainer() + let reconciler = ProjectReconciler(modelContainer: container) + + // First: reconcile a project with a plugin that isn't installed + let parsed = [ + makeParsedProject(name: "TestProject", filePath: "/projects/test.als", plugins: [ + makeParsedPlugin(name: "NewSynth", type: "vst3"), + ]), + ] + _ = try await reconciler.reconcile(parsedProjects: parsed) + + // Verify it's not installed + let context1 = ModelContext(container) + var projects = try context1.fetch(FetchDescriptor()) + #expect(projects[0].plugins[0].isInstalled == false) + + // Now "install" the plugin + let context2 = ModelContext(container) + let installed = Plugin( + name: "NewSynth", + bundleIdentifier: "com.test.newsynth", + format: .vst3, + currentVersion: "1.0.0", + path: "/Library/Audio/Plug-Ins/VST3/NewSynth.vst3" + ) + context2.insert(installed) + try context2.save() + + // Refresh matching + try await reconciler.refreshPluginMatching() + + // Now it should be installed + let context3 = ModelContext(container) + projects = try context3.fetch(FetchDescriptor()) + #expect(projects[0].plugins[0].isInstalled == true) + } + + // MARK: - markMissingProjects Tests + + @Test("markMissingProjects marks unseen as removed") + func markMissingProjectsMarksUnseenAsRemoved() async throws { + let container = try makeContainer() + + let context = ModelContext(container) + for (name, path) in [ + ("A", "/projects/a.als"), + ("B", "/projects/b.als"), + ("C", "/projects/c.als"), + ] { + let project = AbletonProject( + filePath: path, name: name, lastModified: .now, fileSize: 512 + ) + context.insert(project) + } + try context.save() + + let reconciler = ProjectReconciler(modelContainer: container) + let scannedPaths: Set = ["/projects/a.als", "/projects/b.als"] + let removedCount = try await reconciler.markMissingProjects(scannedPaths: scannedPaths) + #expect(removedCount == 1) + + let freshContext = ModelContext(container) + let projects = try freshContext.fetch(FetchDescriptor()) + let removed = projects.filter { $0.isRemoved } + #expect(removed.count == 1) + #expect(removed[0].name == "C") + } + + @Test("markMissingProjects preserves already-removed projects") + func markMissingProjectsPreservesAlreadyRemoved() async throws { + let container = try makeContainer() + + let context = ModelContext(container) + let project = AbletonProject( + filePath: "/projects/old.als", name: "Old", lastModified: .now, fileSize: 512 + ) + project.isRemoved = true + context.insert(project) + try context.save() + + let reconciler = ProjectReconciler(modelContainer: container) + let removedCount = try await reconciler.markMissingProjects(scannedPaths: []) + // Should be 0 because the project was already removed + #expect(removedCount == 0) + } + + @Test("markMissingProjects with all paths scanned removes nothing") + func markMissingProjectsAllScanned() async throws { + let container = try makeContainer() + + let context = ModelContext(container) + for (name, path) in [("A", "/projects/a.als"), ("B", "/projects/b.als")] { + let project = AbletonProject( + filePath: path, name: name, lastModified: .now, fileSize: 512 + ) + context.insert(project) + } + try context.save() + + let reconciler = ProjectReconciler(modelContainer: container) + let removedCount = try await reconciler.markMissingProjects( + scannedPaths: ["/projects/a.als", "/projects/b.als"] + ) + #expect(removedCount == 0) + } + + @Test("Batch reconcile with fullScan false does not remove existing projects") + func batchReconcileNoRemoval() async throws { + let container = try makeContainer() + + // Pre-insert a project + let context = ModelContext(container) + let existing = AbletonProject( + filePath: "/projects/existing.als", name: "Existing", lastModified: .now, fileSize: 512 + ) + context.insert(existing) + try context.save() + + // Reconcile a different project with fullScan=false + let reconciler = ProjectReconciler(modelContainer: container) + let parsed = [ + makeParsedProject(name: "New", filePath: "/projects/new.als"), + ] + let result = try await reconciler.reconcile(parsedProjects: parsed, fullScan: false) + #expect(result.newProjects == 1) + #expect(result.removedProjects == 0) + + // Existing project should still be there and not removed + let freshContext = ModelContext(container) + let projects = try freshContext.fetch(FetchDescriptor()) + #expect(projects.count == 2) + let existingProject = projects.first { $0.name == "Existing" } + #expect(existingProject?.isRemoved == false) + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Views/ProjectDetailViewTests.swift b/PluginUpdater/PluginUpdaterTests/Views/ProjectDetailViewTests.swift new file mode 100644 index 0000000..bf6707d --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Views/ProjectDetailViewTests.swift @@ -0,0 +1,111 @@ +import Testing +import Foundation +import SwiftData +@testable import PluginUpdater + +@Suite("ProjectDetailView Plugin Filtering Tests") +struct ProjectDetailViewTests { + + private func makeContainer() throws -> ModelContainer { + try PersistenceController.makeContainer(inMemory: true) + } + + private func makeProject( + in context: ModelContext, + plugins: [(name: String, type: String, installed: Bool)] + ) throws -> AbletonProject { + let project = AbletonProject( + filePath: "/test/project.als", + name: "Test Project", + lastModified: .now, + fileSize: 1024 + ) + context.insert(project) + for p in plugins { + project.plugins.append(AbletonProjectPlugin( + pluginName: p.name, + pluginType: p.type, + isInstalled: p.installed + )) + } + try context.save() + return project + } + + @Test("AU plugins are filtered and sorted alphabetically") + func auPluginsSortedAlphabetically() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = try makeProject(in: context, plugins: [ + (name: "Zebra2", type: "au", installed: true), + (name: "Pro-L 2", type: "au", installed: true), + (name: "Serum", type: "vst3", installed: true), + (name: "Arpeggiator", type: "au", installed: false), + ]) + + let auPlugins = project.plugins + .filter { $0.pluginType == "au" } + .sorted { $0.pluginName.localizedCompare($1.pluginName) == .orderedAscending } + + #expect(auPlugins.count == 3) + #expect(auPlugins[0].pluginName == "Arpeggiator") + #expect(auPlugins[1].pluginName == "Pro-L 2") + #expect(auPlugins[2].pluginName == "Zebra2") + } + + @Test("VST3 plugins are filtered and sorted alphabetically") + func vst3PluginsSortedAlphabetically() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = try makeProject(in: context, plugins: [ + (name: "Vital", type: "vst3", installed: true), + (name: "Pro-L 2", type: "au", installed: true), + (name: "Diva", type: "vst3", installed: false), + (name: "Serum", type: "vst3", installed: true), + ]) + + let vst3Plugins = project.plugins + .filter { $0.pluginType == "vst3" } + .sorted { $0.pluginName.localizedCompare($1.pluginName) == .orderedAscending } + + #expect(vst3Plugins.count == 3) + #expect(vst3Plugins[0].pluginName == "Diva") + #expect(vst3Plugins[1].pluginName == "Serum") + #expect(vst3Plugins[2].pluginName == "Vital") + } + + @Test("Empty project shows no plugins in any category") + func emptyProjectShowsNoPlugins() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = try makeProject(in: context, plugins: []) + + let auPlugins = project.plugins.filter { $0.pluginType == "au" } + let vst3Plugins = project.plugins.filter { $0.pluginType == "vst3" } + let vst2Plugins = project.plugins.filter { $0.pluginType == "vst2" } + + #expect(auPlugins.isEmpty) + #expect(vst3Plugins.isEmpty) + #expect(vst2Plugins.isEmpty) + #expect(project.plugins.isEmpty) + } + + @Test("VST2 plugins are filtered separately from VST3") + func vst2PluginsFilteredSeparately() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = try makeProject(in: context, plugins: [ + (name: "Sylenth1", type: "vst2", installed: true), + (name: "Serum", type: "vst3", installed: true), + (name: "Kontakt", type: "vst2", installed: false), + ]) + + let vst2Plugins = project.plugins + .filter { $0.pluginType == "vst2" } + .sorted { $0.pluginName.localizedCompare($1.pluginName) == .orderedAscending } + + #expect(vst2Plugins.count == 2) + #expect(vst2Plugins[0].pluginName == "Kontakt") + #expect(vst2Plugins[1].pluginName == "Sylenth1") + } +} diff --git a/PluginUpdater/PluginUpdaterTests/Views/ProjectListViewTests.swift b/PluginUpdater/PluginUpdaterTests/Views/ProjectListViewTests.swift new file mode 100644 index 0000000..10fd3a4 --- /dev/null +++ b/PluginUpdater/PluginUpdaterTests/Views/ProjectListViewTests.swift @@ -0,0 +1,135 @@ +import Testing +import Foundation +@testable import PluginUpdater + +@Suite("ProjectRow Sorting and Filtering Tests") +struct ProjectListViewTests { + + /// Creates a ProjectRow with the specified properties for testing sort/filter logic. + /// Uses a lightweight stub approach since we only need the row's computed properties. + private func makeRow( + name: String, + abletonVersion: String? = nil, + pluginCount: Int = 0, + missingCount: Int = 0, + lastModified: Date = .now, + fileSize: Int64 = 0, + filePath: String = "/tmp/test.als" + ) -> ProjectRow { + // ProjectRow wraps an AbletonProject — create a real model object + let project = AbletonProject( + filePath: filePath, + name: name, + lastModified: lastModified, + fileSize: fileSize, + abletonVersion: abletonVersion + ) + return ProjectRow(project: project) + } + + @Test("ProjectRows sort by name ascending") + func projectRowsSortByNameAscending() { + let rows = [ + makeRow(name: "Zebra Project"), + makeRow(name: "Alpha Project"), + makeRow(name: "Middle Project"), + ] + let sorted = rows.sorted(using: KeyPathComparator(\ProjectRow.name)) + #expect(sorted[0].name == "Alpha Project") + #expect(sorted[1].name == "Middle Project") + #expect(sorted[2].name == "Zebra Project") + } + + @Test("ProjectRows sort by plugin count descending") + func projectRowsSortByPluginCountDescending() { + // Note: pluginCount comes from project.plugins.count which defaults to 0 + // for newly created projects. We test the comparator works correctly. + let rows = [ + makeRow(name: "A", filePath: "/tmp/a.als"), + makeRow(name: "B", filePath: "/tmp/b.als"), + makeRow(name: "C", filePath: "/tmp/c.als"), + ] + let sorted = rows.sorted(using: KeyPathComparator(\ProjectRow.pluginCount, order: .reverse)) + // All have 0 plugins, so order is stable — just verify it doesn't crash + #expect(sorted.count == 3) + } + + @Test("ProjectRows sort by last modified date") + func projectRowsSortByLastModified() { + let now = Date() + let rows = [ + makeRow(name: "Old", lastModified: now.addingTimeInterval(-86400), filePath: "/tmp/old.als"), + makeRow(name: "New", lastModified: now, filePath: "/tmp/new.als"), + makeRow(name: "Mid", lastModified: now.addingTimeInterval(-3600), filePath: "/tmp/mid.als"), + ] + let sorted = rows.sorted(using: KeyPathComparator(\ProjectRow.lastModified, order: .reverse)) + #expect(sorted[0].name == "New") + #expect(sorted[1].name == "Mid") + #expect(sorted[2].name == "Old") + } + + @Test("ProjectRows sort by file size") + func projectRowsSortByFileSize() { + let rows = [ + makeRow(name: "Big", fileSize: 50_000_000, filePath: "/tmp/big.als"), + makeRow(name: "Small", fileSize: 500_000, filePath: "/tmp/small.als"), + makeRow(name: "Medium", fileSize: 5_000_000, filePath: "/tmp/medium.als"), + ] + let sorted = rows.sorted(using: KeyPathComparator(\ProjectRow.fileSize)) + #expect(sorted[0].name == "Small") + #expect(sorted[1].name == "Medium") + #expect(sorted[2].name == "Big") + } + + @Test("ProjectRows sort by missing count descending") + func projectRowsSortByMissingCountDescending() { + // missingCount comes from project.missingPluginCount — defaults to 0 for new projects + let rows = [ + makeRow(name: "A", filePath: "/tmp/a.als"), + makeRow(name: "B", filePath: "/tmp/b.als"), + ] + let sorted = rows.sorted(using: KeyPathComparator(\ProjectRow.missingCount, order: .reverse)) + #expect(sorted.count == 2) + } + + @Test("Filter rows by search text matches case-insensitively") + func projectRowsFilterBySearchText() { + let projects = [ + AbletonProject(filePath: "/tmp/a.als", name: "My Cool Track", lastModified: .now, fileSize: 100), + AbletonProject(filePath: "/tmp/b.als", name: "Another Song", lastModified: .now, fileSize: 200), + AbletonProject(filePath: "/tmp/c.als", name: "cool beans", lastModified: .now, fileSize: 300), + ] + let searchText = "cool" + let filtered = projects.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + #expect(filtered.count == 2) + #expect(filtered.allSatisfy { $0.name.lowercased().contains("cool") }) + } + + @Test("Filter with no match returns empty") + func projectRowsFilterBySearchTextNoMatch() { + let projects = [ + AbletonProject(filePath: "/tmp/a.als", name: "Track One", lastModified: .now, fileSize: 100), + AbletonProject(filePath: "/tmp/b.als", name: "Track Two", lastModified: .now, fileSize: 200), + ] + let filtered = projects.filter { + $0.name.localizedCaseInsensitiveContains("zzzzz") + } + #expect(filtered.isEmpty) + } + + @Test("Empty search text returns all projects") + func projectRowsFilterByEmptySearchReturnsAll() { + let projects = [ + AbletonProject(filePath: "/tmp/a.als", name: "Track One", lastModified: .now, fileSize: 100), + AbletonProject(filePath: "/tmp/b.als", name: "Track Two", lastModified: .now, fileSize: 200), + AbletonProject(filePath: "/tmp/c.als", name: "Track Three", lastModified: .now, fileSize: 300), + ] + let searchText = "" + let filtered = searchText.isEmpty ? projects : projects.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + #expect(filtered.count == 3) + } +} diff --git a/README.md b/README.md index 19dd408..1004479 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,24 @@ Follow [Semantic Versioning](https://semver.org/): - **Minor** (`1.2.4` → `1.3.0`) — new features, backward-compatible - **Major** (`1.3.0` → `2.0.0`) — breaking changes +## Troubleshooting + +### Debug Logging + +By default, project scans produce minimal log output. To enable verbose per-plugin matching logs for troubleshooting: + +```bash +defaults write com.tomioueda.PluginUpdater debugVerboseLogging -bool YES +``` + +Then re-run the scan. Logs are written to `~/Library/Logs/PluginUpdater/` (daily rolling files, kept for 7 days). + +To disable verbose logging: + +```bash +defaults delete com.tomioueda.PluginUpdater debugVerboseLogging +``` + ## License This project is licensed under the [GNU General Public License v3.0](LICENSE).