diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7ce809..f11b193 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,51 @@ jobs: release: runs-on: macos-26 + env: + KEYCHAIN_PATH: ${{ runner.temp }}/build.keychain-db + KEYCHAIN_PASSWORD: ${{ github.run_id }} + steps: - uses: actions/checkout@v4 + - name: Install certificates + env: + DEVELOPER_ID_APPLICATION_P12: ${{ secrets.DEVELOPER_ID_APPLICATION_P12 }} + DEVELOPER_ID_INSTALLER_P12: ${{ secrets.DEVELOPER_ID_INSTALLER_P12 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + run: | + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import Developer ID Application certificate (for app signing) + echo "$DEVELOPER_ID_APPLICATION_P12" | base64 --decode > /tmp/app_cert.p12 + security import /tmp/app_cert.p12 \ + -P "$P12_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Import Developer ID Installer certificate (for pkg signing) + echo "$DEVELOPER_ID_INSTALLER_P12" | base64 --decode > /tmp/installer_cert.p12 + security import /tmp/installer_cert.p12 \ + -P "$P12_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Add keychain to search list + security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') + security default-keychain -s "$KEYCHAIN_PATH" + + # Allow codesign to access keychain without UI prompt + security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Clean up cert files + rm -f /tmp/app_cert.p12 /tmp/installer_cert.p12 - name: Archive App run: | @@ -22,23 +64,24 @@ jobs: -archivePath $PWD/build/abledex.xcarchive \ -configuration Release \ archive \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO + CODE_SIGN_IDENTITY="Developer ID Application: Brett Henderson (94XUGF9CU7)" \ + OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH" - name: Export App run: | - # Creates a simple export options plist cat < ExportOptions.plist method - mac-application - destination - export + developer-id signingStyle manual + signingCertificate + Developer ID Application: Brett Henderson (94XUGF9CU7) + teamID + 94XUGF9CU7 EOF @@ -46,17 +89,59 @@ jobs: xcodebuild -exportArchive \ -archivePath $PWD/build/abledex.xcarchive \ -exportOptionsPlist ExportOptions.plist \ - -exportPath $PWD/build \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO + -exportPath $PWD/build + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - - name: Zip Application + - name: Store notarization credentials + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} run: | - cd build - zip -r abledex.zip abledex.app + xcrun notarytool store-credentials "notary-profile" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "94XUGF9CU7" \ + --keychain "$KEYCHAIN_PATH" + + - name: Build, sign, notarize, and staple pkg + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + + # Build unsigned pkg + pkgbuild \ + --component build/abledex.app \ + --install-location /Applications \ + --identifier computerdata.abledex \ + --version "$VERSION" \ + build/abledex-unsigned.pkg + + # Sign pkg with Developer ID Installer certificate + productsign \ + --sign "Developer ID Installer: Brett Henderson (94XUGF9CU7)" \ + --keychain "$KEYCHAIN_PATH" \ + build/abledex-unsigned.pkg \ + build/abledex.pkg + + rm build/abledex-unsigned.pkg + + # Notarize using stored credentials (avoids secrets in process args) + xcrun notarytool submit build/abledex.pkg \ + --keychain-profile "notary-profile" \ + --keychain "$KEYCHAIN_PATH" \ + --wait + + # Staple the notarization ticket + xcrun stapler staple build/abledex.pkg - name: Release uses: softprops/action-gh-release@v1 with: - files: build/abledex.zip + files: build/abledex.pkg generate_release_notes: true + + - name: Cleanup keychain + if: always() + run: security delete-keychain "$KEYCHAIN_PATH" diff --git a/abledex.xcodeproj/project.pbxproj b/abledex.xcodeproj/project.pbxproj index 8297ae0..3fa2831 100644 --- a/abledex.xcodeproj/project.pbxproj +++ b/abledex.xcodeproj/project.pbxproj @@ -130,7 +130,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2620; TargetAttributes = { A60284F72EEE2E9D00893D52 = { CreatedOnToolsVersion = 26.0.1; @@ -241,6 +241,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -266,6 +267,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -305,6 +307,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_NS_ASSERTIONS = NO; @@ -323,6 +326,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -335,7 +339,8 @@ CODE_SIGN_ENTITLEMENTS = abledex/abledex.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -370,7 +375,8 @@ CODE_SIGN_ENTITLEMENTS = abledex/abledex.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -403,6 +409,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.6; @@ -424,6 +431,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.6; diff --git a/abledex/AppState.swift b/abledex/AppState.swift index 6c2ffbf..a7d6788 100644 --- a/abledex/AppState.swift +++ b/abledex/AppState.swift @@ -21,33 +21,153 @@ final class AppState { // MARK: - State - var projects: [ProjectRecord] = [] + var projects: [ProjectRecord] = [] { + didSet { + // Skip if this is the initial load (caches already populated) + guard !isInitialLoad else { return } + recomputeCachedCounts() + recomputeFilteredProjects() + } + } + private var isInitialLoad = true var locations: [LocationRecord] = [] var selectedProjectIDs: Set = [] - var searchQuery: String = "" + var searchQuery: String = "" { + didSet { + // Debounce search to avoid filtering on every keystroke + searchDebounceTask?.cancel() + searchDebounceTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(150)) + if !Task.isCancelled { + recomputeFilteredProjects() + } + } + } + } + + // MARK: - Cached Data (for sidebar performance) + private(set) var statusCounts: [CompletionStatus: Int] = [:] + private(set) var colorLabelCounts: [ColorLabel: Int] = [:] + private(set) var volumeCounts: [String: Int] = [:] + private(set) var tagCounts: [String: Int] = [:] + private(set) var pluginCounts: [String: Int] = [:] + private(set) var keyCounts: [String: Int] = [:] + private(set) var folderCounts: [String: Int] = [:] + + // Cached unique values (avoid recomputing on every render) + private(set) var cachedUniqueVolumes: [String] = [] + private(set) var cachedUniqueTags: [String] = [] + private(set) var cachedUniquePlugins: [String] = [] + private(set) var cachedUniqueKeys: [String] = [] + private(set) var cachedUniqueFolders: [String] = [] + private(set) var cachedFoldersWithMultipleVersions: [String] = [] + private(set) var cachedProjectsByFolder: [String: [ProjectRecord]] = [:] + private(set) var cachedDuplicateGroups: [DuplicateGroup] = [] + private(set) var cachedDuplicatesCount: Int = 0 + + private func recomputeCachedCounts() { + var newStatusCounts: [CompletionStatus: Int] = [:] + var newColorLabelCounts: [ColorLabel: Int] = [:] + var newVolumeCounts: [String: Int] = [:] + var newTagCounts: [String: Int] = [:] + var newPluginCounts: [String: Int] = [:] + var newKeyCounts: [String: Int] = [:] + var newFolderCounts: [String: Int] = [:] + + for project in projects { + // Status + newStatusCounts[project.completionStatus, default: 0] += 1 + + // Color label + newColorLabelCounts[project.colorLabel, default: 0] += 1 + + // Volume + newVolumeCounts[project.sourceVolume, default: 0] += 1 + + // Folder + newFolderCounts[project.projectFolderName, default: 0] += 1 + + // Tags + for tag in project.userTags { + newTagCounts[tag, default: 0] += 1 + } + + // Plugins + for plugin in project.plugins { + newPluginCounts[plugin, default: 0] += 1 + } + + // Keys + for key in project.musicalKeys { + newKeyCounts[key, default: 0] += 1 + } + } + + statusCounts = newStatusCounts + colorLabelCounts = newColorLabelCounts + volumeCounts = newVolumeCounts + tagCounts = newTagCounts + pluginCounts = newPluginCounts + keyCounts = newKeyCounts + folderCounts = newFolderCounts + + // Compute unique sorted arrays from the count dictionaries + cachedUniqueVolumes = newVolumeCounts.keys.sorted() + cachedUniqueTags = newTagCounts.keys.sorted() + cachedUniquePlugins = newPluginCounts.keys.sorted() + cachedUniqueKeys = newKeyCounts.keys.sorted() + cachedUniqueFolders = newFolderCounts.keys.sorted() + cachedFoldersWithMultipleVersions = newFolderCounts.filter { $0.value > 1 }.keys.sorted() + cachedProjectsByFolder = Dictionary(grouping: projects, by: { $0.projectFolderName }) + + // Recompute duplicates in background (O(n²) operation) + let projectsSnapshot = self.projects + Task.detached(priority: .utility) { + let groups = await DuplicateDetectionService().findDuplicates(in: projectsSnapshot) + let count = Set(groups.flatMap { $0.projects.map { $0.id } }).count + await MainActor.run { [groups, count] in + // Update state on the main actor + // Accessing self here is safe as this closure executes on MainActor + // and we didn't capture self in the detached task + let update: @MainActor () -> Void = { + self.cachedDuplicateGroups = groups + self.cachedDuplicatesCount = count + } + update() + } + } + } var isScanning: Bool = false var scanProgress: ScanProgress? // Sorting - var sortColumn: SortColumn = .modifiedDate - var sortAscending: Bool = false - - // Filtering - var selectedFilter: ProjectFilter = .all - var selectedVolumeFilter: String? - var selectedStatusFilter: CompletionStatus? - var selectedColorLabelFilter: ColorLabel? - var selectedTagFilter: String? - var selectedPluginFilter: String? - var selectedKeyFilter: String? - var selectedFolderFilter: String? - var showFavoritesOnly: Bool = false - var showDuplicatesOnly: Bool = false + var sortColumn: SortColumn = .modifiedDate { didSet { recomputeFilteredProjects() } } + var sortAscending: Bool = false { didSet { recomputeFilteredProjects() } } + + // Filtering (with didSet to trigger recomputation) + var selectedFilter: ProjectFilter = .all { didSet { recomputeFilteredProjects() } } + var selectedVolumeFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedStatusFilter: CompletionStatus? { didSet { recomputeFilteredProjects() } } + var selectedColorLabelFilter: ColorLabel? { didSet { recomputeFilteredProjects() } } + var selectedTagFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedPluginFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedKeyFilter: String? { didSet { recomputeFilteredProjects() } } + var selectedFolderFilter: String? { didSet { recomputeFilteredProjects() } } + var showFavoritesOnly: Bool = false { didSet { recomputeFilteredProjects() } } + var showDuplicatesOnly: Bool = false { didSet { recomputeFilteredProjects() } } + + // Cached filtered projects + private(set) var cachedFilteredProjects: [ProjectRecord] = [] + private var searchDebounceTask: Task? // MARK: - Computed Properties var filteredProjects: [ProjectRecord] { + cachedFilteredProjects + } + + private func recomputeFilteredProjects() { var result = projects // Apply search filter (includes name, plugins, and tags) @@ -154,7 +274,7 @@ final class AppState { return sortAscending ? comparison : !comparison } - return result + cachedFilteredProjects = result } var selectedProject: ProjectRecord? { @@ -168,27 +288,27 @@ final class AppState { } var uniqueVolumes: [String] { - Array(Set(projects.map { $0.sourceVolume })).sorted() + cachedUniqueVolumes } var uniqueTags: [String] { - Array(Set(projects.flatMap { $0.userTags })).sorted() + cachedUniqueTags } var uniquePlugins: [String] { - Array(Set(projects.flatMap { $0.plugins })).sorted() + cachedUniquePlugins } var uniqueKeys: [String] { - Array(Set(projects.flatMap { $0.musicalKeys })).sorted() + cachedUniqueKeys } var uniqueFolders: [String] { - Array(Set(projects.map { $0.projectFolderName })).sorted() + cachedUniqueFolders } var projectsByFolder: [String: [ProjectRecord]] { - Dictionary(grouping: projects, by: { $0.projectFolderName }) + cachedProjectsByFolder } func versionsInSameFolder(as project: ProjectRecord) -> [ProjectRecord] { @@ -197,11 +317,11 @@ final class AppState { } var duplicateGroups: [DuplicateGroup] { - duplicateService.findDuplicates(in: projects) + cachedDuplicateGroups } var duplicatesCount: Int { - Set(duplicateGroups.flatMap { $0.projects.map { $0.id } }).count + cachedDuplicatesCount } func hasDuplicates(_ project: ProjectRecord) -> Bool { @@ -238,8 +358,38 @@ final class AppState { func loadData() async { do { - projects = try await database.fetchAllProjects() - locations = try await database.fetchAllLocations() + // Fetch from database (off main thread via async) + let fetchedProjects = try await database.fetchAllProjects() + let fetchedLocations = try await database.fetchAllLocations() + + // Compute caches off main thread + let caches = await Task.detached(priority: .userInitiated) { + self.computeCachesOffMainThread(for: fetchedProjects) + }.value + + // Apply to state (on main thread, but just assignments) + locations = fetchedLocations + statusCounts = caches.statusCounts + colorLabelCounts = caches.colorLabelCounts + volumeCounts = caches.volumeCounts + tagCounts = caches.tagCounts + pluginCounts = caches.pluginCounts + keyCounts = caches.keyCounts + folderCounts = caches.folderCounts + cachedUniqueVolumes = caches.uniqueVolumes + cachedUniqueTags = caches.uniqueTags + cachedUniquePlugins = caches.uniquePlugins + cachedUniqueKeys = caches.uniqueKeys + cachedUniqueFolders = caches.uniqueFolders + cachedFoldersWithMultipleVersions = caches.foldersWithMultipleVersions + cachedProjectsByFolder = caches.projectsByFolder + cachedFilteredProjects = caches.filteredProjects + cachedDuplicateGroups = caches.duplicateGroups + cachedDuplicatesCount = caches.duplicatesCount + + // Set projects (didSet skipped due to isInitialLoad flag) + projects = fetchedProjects + isInitialLoad = false if locations.isEmpty { await initializeDefaultLocations() @@ -249,6 +399,75 @@ final class AppState { } } + private struct ComputedCaches: Sendable { + var statusCounts: [CompletionStatus: Int] + var colorLabelCounts: [ColorLabel: Int] + var volumeCounts: [String: Int] + var tagCounts: [String: Int] + var pluginCounts: [String: Int] + var keyCounts: [String: Int] + var folderCounts: [String: Int] + var uniqueVolumes: [String] + var uniqueTags: [String] + var uniquePlugins: [String] + var uniqueKeys: [String] + var uniqueFolders: [String] + var foldersWithMultipleVersions: [String] + var projectsByFolder: [String: [ProjectRecord]] + var filteredProjects: [ProjectRecord] + var duplicateGroups: [DuplicateGroup] + var duplicatesCount: Int + } + + private nonisolated func computeCachesOffMainThread(for projects: [ProjectRecord]) -> ComputedCaches { + var statusCounts: [CompletionStatus: Int] = [:] + var colorLabelCounts: [ColorLabel: Int] = [:] + var volumeCounts: [String: Int] = [:] + var tagCounts: [String: Int] = [:] + var pluginCounts: [String: Int] = [:] + var keyCounts: [String: Int] = [:] + var folderCounts: [String: Int] = [:] + + for project in projects { + statusCounts[project.completionStatus, default: 0] += 1 + colorLabelCounts[project.colorLabel, default: 0] += 1 + volumeCounts[project.sourceVolume, default: 0] += 1 + folderCounts[project.projectFolderName, default: 0] += 1 + for tag in project.userTags { tagCounts[tag, default: 0] += 1 } + for plugin in project.plugins { pluginCounts[plugin, default: 0] += 1 } + for key in project.musicalKeys { keyCounts[key, default: 0] += 1 } + } + + // Sort projects by modified date descending (default sort) + let sortedProjects = projects.sorted { + ($0.modifiedDate ?? $0.filesystemModifiedDate) > ($1.modifiedDate ?? $1.filesystemModifiedDate) + } + + // Compute duplicates (O(n²) but done off main thread) + let duplicateGroups = DuplicateDetectionService().findDuplicates(in: projects) + let duplicatesCount = Set(duplicateGroups.flatMap { $0.projects.map { $0.id } }).count + + return ComputedCaches( + statusCounts: statusCounts, + colorLabelCounts: colorLabelCounts, + volumeCounts: volumeCounts, + tagCounts: tagCounts, + pluginCounts: pluginCounts, + keyCounts: keyCounts, + folderCounts: folderCounts, + uniqueVolumes: volumeCounts.keys.sorted(), + uniqueTags: tagCounts.keys.sorted(), + uniquePlugins: pluginCounts.keys.sorted(), + uniqueKeys: keyCounts.keys.sorted(), + uniqueFolders: folderCounts.keys.sorted(), + foldersWithMultipleVersions: folderCounts.filter { $0.value > 1 }.keys.sorted(), + projectsByFolder: Dictionary(grouping: projects, by: { $0.projectFolderName }), + filteredProjects: sortedProjects, + duplicateGroups: duplicateGroups, + duplicatesCount: duplicatesCount + ) + } + private func initializeDefaultLocations() async { let defaultPaths = FileSystemCrawler.defaultScanLocations() @@ -271,14 +490,28 @@ final class AppState { isScanning = true scanProgress = .starting - do { - _ = try await scanner.scanAllLocations { [weak self] progress in - Task { @MainActor [weak self] in - self?.scanProgress = progress + // Run scan in detached task to avoid blocking MainActor + let scanner = self.scanner + let result: Result = await Task.detached(priority: .userInitiated) { + do { + let count = try await scanner.scanAllLocations { progress in + Task { @MainActor in + // Use weak reference pattern inline + await MainActor.run { [weak self] in + self?.scanProgress = progress + } + } } + return .success(count) + } catch { + return .failure(error) } + }.value + + switch result { + case .success: await loadData() - } catch { + case .failure(let error): print("Scan failed: \(error)") scanProgress = .failed(error) } @@ -492,7 +725,7 @@ final class AppState { } func colorLabelCount(for label: ColorLabel) -> Int { - projects.filter { $0.colorLabel == label }.count + colorLabelCounts[label] ?? 0 } func clearAllFilters() { diff --git a/abledex/Assets.xcassets/logo.imageset/Contents.json b/abledex/Assets.xcassets/logo.imageset/Contents.json index 95e9744..a60d29e 100644 --- a/abledex/Assets.xcassets/logo.imageset/Contents.json +++ b/abledex/Assets.xcassets/logo.imageset/Contents.json @@ -2,16 +2,7 @@ "images" : [ { "filename" : "abledex2pink.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { diff --git a/abledex/Assets.xcassets/logopdf.imageset/Contents.json b/abledex/Assets.xcassets/logopdf.imageset/Contents.json new file mode 100644 index 0000000..8f7e3b3 --- /dev/null +++ b/abledex/Assets.xcassets/logopdf.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "abelyedex.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/abledex/Assets.xcassets/logopdf.imageset/abelyedex.pdf b/abledex/Assets.xcassets/logopdf.imageset/abelyedex.pdf new file mode 100644 index 0000000..abaf0d5 Binary files /dev/null and b/abledex/Assets.xcassets/logopdf.imageset/abelyedex.pdf differ diff --git a/abledex/Database/ProjectRecord.swift b/abledex/Database/ProjectRecord.swift index e3234bd..a8b7821 100644 --- a/abledex/Database/ProjectRecord.swift +++ b/abledex/Database/ProjectRecord.swift @@ -185,17 +185,52 @@ struct ProjectRecord: Codable, Sendable, Identifiable, FetchableRecord, Persista } } +// MARK: - JSON Decoding Cache + +/// Thread-safe cache for decoded JSON arrays to avoid repeated decoding +private final class JSONDecodeCache: @unchecked Sendable { + static let shared = JSONDecodeCache() + + private var cache: [String: [String]] = [:] + private let lock = NSLock() + + func get(_ json: String?) -> [String]? { + guard let json = json else { return nil } + lock.lock() + defer { lock.unlock() } + return cache[json] + } + + func set(_ json: String, value: [String]) { + lock.lock() + defer { lock.unlock() } + cache[json] = value + } + + func decode(_ json: String?) -> [String] { + guard let json = json else { return [] } + + // Check cache first + if let cached = get(json) { + return cached + } + + // Decode and cache + guard let data = json.data(using: .utf8), + let decoded = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + set(json, value: decoded) + return decoded + } +} + // MARK: - Convenience accessors for JSON fields extension ProjectRecord { var samplePaths: [String] { get { - guard let json = samplePathsJSON, - let data = json.data(using: .utf8), - let paths = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return paths + JSONDecodeCache.shared.decode(samplePathsJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -207,12 +242,7 @@ extension ProjectRecord { var plugins: [String] { get { - guard let json = pluginsJSON, - let data = json.data(using: .utf8), - let plugins = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return plugins + JSONDecodeCache.shared.decode(pluginsJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -224,12 +254,7 @@ extension ProjectRecord { var userTags: [String] { get { - guard let json = userTagsJSON, - let data = json.data(using: .utf8), - let tags = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return tags + JSONDecodeCache.shared.decode(userTagsJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -241,12 +266,7 @@ extension ProjectRecord { var musicalKeys: [String] { get { - guard let json = musicalKeysJSON, - let data = json.data(using: .utf8), - let keys = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return keys + JSONDecodeCache.shared.decode(musicalKeysJSON) } set { if let data = try? JSONEncoder().encode(newValue), @@ -264,7 +284,11 @@ extension ProjectRecord { } var projectFolderName: String { - URL(fileURLWithPath: folderPath).lastPathComponent + // Use string manipulation instead of URL for performance + if let lastSlash = folderPath.lastIndex(of: "/") { + return String(folderPath[folderPath.index(after: lastSlash)...]) + } + return folderPath } var formattedDuration: String? { diff --git a/abledex/Scanner/ALSParser.swift b/abledex/Scanner/ALSParser.swift index a250ea1..244df5c 100644 --- a/abledex/Scanner/ALSParser.swift +++ b/abledex/Scanner/ALSParser.swift @@ -89,6 +89,22 @@ struct ALSParser: Sendable { return parseXML(xmlString) } + /// Returns the raw decompressed XML string from an ALS file + nonisolated func getRawXML(alsFilePath: URL) throws -> String { + guard FileManager.default.fileExists(atPath: alsFilePath.path) else { + throw ALSParserError.fileNotFound + } + + let compressedData = try Data(contentsOf: alsFilePath) + let xmlData = try decompressGzip(data: compressedData) + + guard let xmlString = String(data: xmlData, encoding: .utf8) else { + throw ALSParserError.invalidXML + } + + return xmlString + } + private nonisolated func decompressGzip(data: Data) throws -> Data { guard data.count > 10 else { throw ALSParserError.decompressionFailed @@ -161,11 +177,9 @@ struct ALSParser: Sendable { // Parse Ableton version - look in first 2000 chars for efficiency let headerSection = String(xmlString.prefix(2000)) - if let range = headerSection.range(of: #"Creator="Ableton Live ([^"]+)""#, options: .regularExpression) { - let match = headerSection[range] - if let versionRange = match.range(of: #"(?<=Creator="Ableton Live )[^"]+"#, options: .regularExpression) { - result.abletonVersion = String(match[versionRange]) - } + if let creatorStart = headerSection.range(of: "Creator=\"Ableton Live "), + let creatorEnd = headerSection.range(of: "\"", range: creatorStart.upperBound..[^<]*<[^>]*Numerator Value="(\d+)""#) ?? 4 result.timeSignatureDenominator = extractFirstInt(from: xmlString, pattern: #"[^<]*<[^>]*Denominator Value="(\d+)""#) ?? 4 - // Count tracks - use simple string counting for speed - result.audioTrackCount = xmlString.components(separatedBy: " Int { + var count = 0 + var searchRange = haystack.startIndex.. Double? { // Find the Tempo block and extract the Manual value // The structure is: ...... @@ -206,22 +231,15 @@ struct ALSParser: Sendable { return nil } - let tempoBlock = String(xmlString[tempoStart.lowerBound.. within the Tempo block - let pattern = #" 1, - let valueRange = Range(match.range(at: 1), in: tempoBlock) else { + // Fast string-based extraction without regex + guard let manualStart = tempoBlock.range(of: " Double? { diff --git a/abledex/Scanner/ProjectScanner.swift b/abledex/Scanner/ProjectScanner.swift index 369db2a..3bb893d 100644 --- a/abledex/Scanner/ProjectScanner.swift +++ b/abledex/Scanner/ProjectScanner.swift @@ -14,6 +14,21 @@ enum ScanProgress: Sendable { case parsing(current: Int, total: Int, projectName: String) case completed(projectCount: Int, duration: TimeInterval) case failed(Error) + + var description: String { + switch self { + case .starting: + return "starting" + case .discovering(let location): + return "discovering-\(location)" + case .parsing(let current, _, let name): + return "parsing-\(current)-\(name)" + case .completed(let count, _): + return "completed-\(count)" + case .failed: + return "failed" + } + } } final class ProjectScanner: Sendable { @@ -31,11 +46,20 @@ final class ProjectScanner: Sendable { progress(.starting) let locations = try await database.fetchEnabledLocations() - var totalProjects = 0 - for location in locations { - let count = try await scanLocation(location, progress: progress) - totalProjects += count + // Scan all locations in parallel + let totalProjects = try await withThrowingTaskGroup(of: Int.self) { group in + for location in locations { + group.addTask { + try await self.scanLocation(location, progress: progress) + } + } + + var total = 0 + for try await count in group { + total += count + } + return total } let duration = Date().timeIntervalSince(startTime) @@ -67,16 +91,24 @@ final class ProjectScanner: Sendable { // Parse in batches to avoid memory pressure var processed = 0 - let batchSize = 10 + let batchSize = 50 + var pendingSave: Task? = nil for batch in stride(from: 0, to: total, by: batchSize) { let end = min(batch + batchSize, total) let batchProjects = Array(discoveredProjects[batch.. String? { - guard let data = try? Data(contentsOf: url) else { return nil } - let digest = Insecure.MD5.hash(data: data) + // Use file size + first/last 64KB for fast hashing instead of reading entire file + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let fileSize = attrs[.size] as? UInt64 else { return nil } + + let sampleSize = min(65536, Int(fileSize)) // 64KB or less + + var hasher = Insecure.MD5() + + // Hash file size + var size = fileSize + withUnsafeBytes(of: &size) { hasher.update(bufferPointer: $0) } + + // Hash first chunk + if let firstChunk = try? handle.read(upToCount: sampleSize) { + hasher.update(data: firstChunk) + } + + // Hash last chunk if file is large enough + if fileSize > UInt64(sampleSize * 2) { + try? handle.seek(toOffset: fileSize - UInt64(sampleSize)) + if let lastChunk = try? handle.read(upToCount: sampleSize) { + hasher.update(data: lastChunk) + } + } + + let digest = hasher.finalize() return digest.map { String(format: "%02hhx", $0) }.joined() } } diff --git a/abledex/Views/ProjectList/ProjectDetailView.swift b/abledex/Views/ProjectList/ProjectDetailView.swift index e031b34..b1ff25d 100644 --- a/abledex/Views/ProjectList/ProjectDetailView.swift +++ b/abledex/Views/ProjectList/ProjectDetailView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit struct ProjectDetailView: View { let project: ProjectRecord @@ -17,6 +18,7 @@ struct ProjectDetailView: View { @State private var isEditingNotes: Bool = false @State private var previewableAudio: [AudioPreviewService.PreviewableAudio] = [] @State private var sectionOrder: [DetailSection] = DetailOrderStorage.order + @State private var showXMLViewer: Bool = false var body: some View { ScrollView { @@ -172,6 +174,16 @@ struct ProjectDetailView: View { } .buttonStyle(.bordered) + Button { + showXMLViewer = true + } label: { + Label("XML", systemImage: "doc.text") + } + .buttonStyle(.bordered) + .sheet(isPresented: $showXMLViewer) { + XMLViewerSheet(project: project) + } + Spacer() } } @@ -721,7 +733,148 @@ struct ProjectDetailView: View { // MARK: - Supporting Views +struct XMLViewerSheet: View { + let project: ProjectRecord + @Environment(\.dismiss) private var dismiss + @State private var xmlContent: String = "" + @State private var isLoading: Bool = true + @State private var errorMessage: String? + @State private var xmlSize: Int = 0 + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("XML: \(project.name)") + .font(.headline) + if xmlSize > 0 { + Text("(\(ByteCountFormatter.string(fromByteCount: Int64(xmlSize), countStyle: .file)))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(xmlContent, forType: .string) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .disabled(xmlContent.isEmpty) + + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + } + .padding() + + Divider() + + // Content + if isLoading { + Spacer() + ProgressView("Loading XML...") + Spacer() + } else if let error = errorMessage { + Spacer() + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(error) + .foregroundStyle(.secondary) + } + Spacer() + } else { + XMLTextView(text: xmlContent) + } + } + .frame(minWidth: 700, minHeight: 500) + .task { + await loadXML() + } + } + + private func loadXML() async { + let filePath = URL(fileURLWithPath: project.alsFilePath) + + // Run on background thread with low priority to avoid competing with scan I/O + let result: Result = await Task.detached(priority: .background) { + let parser = ALSParser() + return Result { try parser.getRawXML(alsFilePath: filePath) } + }.value + + switch result { + case .success(let xml): + xmlContent = xml + xmlSize = xml.utf8.count + isLoading = false + case .failure(let error): + errorMessage = error.localizedDescription + isLoading = false + } + } +} + +/// NSTextView wrapper for performant large text display. +/// Uses Coordinator to set text off the main layout pass to avoid UI stalls. +struct XMLTextView: NSViewRepresentable { + let text: String + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + + textView.isEditable = false + textView.isSelectable = true + textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + textView.backgroundColor = NSColor.textBackgroundColor + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + + // Disable word wrap for XML (horizontal scroll) + textView.isHorizontallyResizable = true + textView.textContainer?.widthTracksTextView = false + textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + + // Disable layout during initial load + textView.layoutManager?.allowsNonContiguousLayout = true + + context.coordinator.textView = textView + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard context.coordinator.currentText != text else { return } + context.coordinator.currentText = text + + let textView = context.coordinator.textView! + // Set text content with layout temporarily disabled to avoid stalling + textView.textStorage?.beginEditing() + textView.textStorage?.setAttributedString(NSAttributedString( + string: text, + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .foregroundColor: NSColor.textColor + ] + )) + textView.textStorage?.endEditing() + } + + class Coordinator { + var textView: NSTextView? + var currentText: String = "" + } +} struct FlowLayout: Layout { var spacing: CGFloat = 8 diff --git a/abledex/Views/Sidebar/SidebarView.swift b/abledex/Views/Sidebar/SidebarView.swift index 1fb9dba..3b63113 100644 --- a/abledex/Views/Sidebar/SidebarView.swift +++ b/abledex/Views/Sidebar/SidebarView.swift @@ -20,7 +20,8 @@ struct SidebarView: View { List(selection: $state.selectedFilter) { HStack(alignment: .center) { - Image(.logo) + Image(.logopdf) + .renderingMode(.template) .resizable() .scaledToFit() .frame(width: 32, height: 32) @@ -531,10 +532,7 @@ struct SidebarView: View { } private var foldersWithMultipleVersions: [String] { - appState.projectsByFolder - .filter { $0.value.count > 1 } - .keys - .sorted() + appState.cachedFoldersWithMultipleVersions } // MARK: - Helper Functions @@ -555,32 +553,67 @@ struct SidebarView: View { @ViewBuilder private var scanProgressView: some View { if let progress = appState.scanProgress { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { switch progress { case .starting: - Text("Starting scan...") - .font(.caption) + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.7) + Text("Preparing scan...") + .font(.caption) + } case .discovering(let location): - Text("Discovering in \(location)...") - .font(.caption) + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.7) + Text("Finding projects in \(location)...") + .font(.caption) + .lineLimit(1) + } case .parsing(let current, let total, let name): - Text("Parsing: \(name)") - .font(.caption) - .lineLimit(1) - ProgressView(value: Double(current), total: Double(total)) - Text("\(current) of \(total)") - .font(.caption2) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "doc.text.magnifyingglass") + .foregroundStyle(.secondary) + .font(.caption) + Text(name) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + } + ProgressView(value: Double(current), total: Double(total)) + .animation(.easeInOut(duration: 0.2), value: current) + HStack { + Text("\(current) of \(total) projects") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int((Double(current) / Double(total)) * 100))%") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + } + } case .completed(let count, let duration): - Text("Found \(count) projects in \(String(format: "%.1f", duration))s") - .font(.caption) + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Found \(count) projects in \(String(format: "%.1f", duration))s") + .font(.caption) + } case .failed(let error): - Text("Error: \(error.localizedDescription)") - .font(.caption) - .foregroundStyle(.red) + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } } } .padding(.horizontal) + .animation(.easeInOut(duration: 0.15), value: appState.scanProgress?.description) } } @@ -616,23 +649,23 @@ struct SidebarView: View { } private func projectCount(for volume: String) -> Int { - appState.projects.filter { $0.sourceVolume == volume }.count + appState.volumeCounts[volume] ?? 0 } private func tagCount(for tag: String) -> Int { - appState.projects.filter { $0.userTags.contains(tag) }.count + appState.tagCounts[tag] ?? 0 } private func pluginCount(for plugin: String) -> Int { - appState.projects.filter { $0.plugins.contains(plugin) }.count + appState.pluginCounts[plugin] ?? 0 } private func keyCount(for key: String) -> Int { - appState.projects.filter { $0.musicalKeys.contains(key) }.count + appState.keyCounts[key] ?? 0 } private func folderCount(for folder: String) -> Int { - appState.projectsByFolder[folder]?.count ?? 0 + appState.folderCounts[folder] ?? 0 } private func displayKey(_ key: String) -> String { @@ -651,7 +684,7 @@ struct SidebarView: View { } private func statusCount(for status: CompletionStatus) -> Int { - appState.projects.filter { $0.completionStatus == status }.count + appState.statusCounts[status] ?? 0 } private func statusColor(for status: CompletionStatus) -> Color { diff --git a/abledex/Views/StatisticsView.swift b/abledex/Views/StatisticsView.swift index 9564dd3..6d494a9 100644 --- a/abledex/Views/StatisticsView.swift +++ b/abledex/Views/StatisticsView.swift @@ -12,6 +12,11 @@ struct StatisticsView: View { @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + // Cached storage data to avoid synchronous file I/O on every render + @State private var cachedStorageByVolume: [(volume: String, size: Int64, count: Int)] = [] + @State private var cachedTotalStorage: Int64 = 0 + @State private var isLoadingStorage = true + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { @@ -113,13 +118,21 @@ struct StatisticsView: View { Text("Storage by Volume") .font(.headline) - if storageByVolume.isEmpty { + if isLoadingStorage { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Calculating storage...") + .foregroundStyle(.secondary) + .font(.caption) + } + } else if cachedStorageByVolume.isEmpty { Text("No volume data available") .foregroundStyle(.secondary) .font(.caption) } else { VStack(spacing: 8) { - ForEach(storageByVolume, id: \.volume) { item in + ForEach(cachedStorageByVolume, id: \.volume) { item in Button { appState.clearAllFilters() appState.selectedVolumeFilter = item.volume @@ -145,7 +158,7 @@ struct StatisticsView: View { HStack { Spacer() - Text("Total: \(formatBytes(totalStorageSize))") + Text("Total: \(formatBytes(cachedTotalStorage))") .font(.subheadline) .foregroundStyle(.secondary) } @@ -358,6 +371,44 @@ struct StatisticsView: View { .padding() } .frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 700) + .task { + await loadStorageData() + } + } + + // MARK: - Async Storage Loading + + private func loadStorageData() async { + let projects = appState.projects + let result = await Task.detached(priority: .userInitiated) { + var byVolume: [String: (size: Int64, count: Int)] = [:] + var total: Int64 = 0 + + for project in projects { + let url = URL(fileURLWithPath: project.alsFilePath) + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? UInt64 { + let sizeInt64 = Int64(size) + total += sizeInt64 + + let volume = project.sourceVolume + if let existing = byVolume[volume] { + byVolume[volume] = (existing.size + sizeInt64, existing.count + 1) + } else { + byVolume[volume] = (sizeInt64, 1) + } + } + } + + let sorted = byVolume.map { (volume: $0.key, size: $0.value.size, count: $0.value.count) } + .sorted { $0.size > $1.size } + + return (sorted, total) + }.value + + cachedStorageByVolume = result.0 + cachedTotalStorage = result.1 + isLoadingStorage = false } // MARK: - Computed Stats @@ -447,32 +498,6 @@ struct StatisticsView: View { // MARK: - Storage Stats - private var totalStorageSize: Int64 { - appState.projects.reduce(Int64(0)) { sum, project in - let url = URL(fileURLWithPath: project.alsFilePath) - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? UInt64 { - return sum + Int64(size) - } - return sum - } - } - - private var storageByVolume: [(volume: String, size: Int64, count: Int)] { - let grouped = Dictionary(grouping: appState.projects, by: { $0.sourceVolume }) - return grouped.map { (volume, projects) in - let size = projects.reduce(Int64(0)) { sum, project in - let url = URL(fileURLWithPath: project.alsFilePath) - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let fileSize = attrs[.size] as? UInt64 { - return sum + Int64(fileSize) - } - return sum - } - return (volume, size, projects.count) - }.sorted { $0.size > $1.size } - } - private func formatBytes(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file