// // DiskAnalyzer.swift // MUA // // Created by Mitchel Volkering on 21/12/2025. // import Foundation /// Thread-safe progress reporter for disk scanning final class ScanProgress: @unchecked Sendable { private let lock = NSLock() private var _currentPath: String = "" private var _scannedCount: Int = 0 private var _totalTopLevel: Int = 0 private var _completedTopLevel: Int = 0 var currentPath: String { lock.lock() defer { lock.unlock() } return _currentPath } var scannedCount: Int { lock.lock() defer { lock.unlock() } return _scannedCount } var progress: Double { lock.lock() defer { lock.unlock() } guard _totalTopLevel > 0 else { return 0 } return Double(_completedTopLevel) / Double(_totalTopLevel) } func update(path: String) { lock.lock() _currentPath = path _scannedCount += 1 lock.unlock() } func setTotalTopLevel(_ count: Int) { lock.lock() _totalTopLevel = count lock.unlock() } func completeTopLevel() { lock.lock() _completedTopLevel += 1 lock.unlock() } } /// Service for analyzing disk space usage @Observable @MainActor final class DiskAnalyzer { var rootItem: FileItem? var isScanning = false var scanProgress: Double = 0 var currentPath: String = "" var scannedCount: Int = 0 var error: String? private var progressReporter: ScanProgress? private var progressTask: Task? /// Analyze a directory and build the file tree func analyze(url: URL) async { isScanning = true scanProgress = 0 scannedCount = 0 error = nil currentPath = url.path let scanURL = url let progress = ScanProgress() self.progressReporter = progress // Start progress monitoring task progressTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(100)) guard let self = self, self.isScanning else { break } self.currentPath = progress.currentPath self.scannedCount = progress.scannedCount self.scanProgress = progress.progress } } // Perform heavy scanning on background thread using static helper let result = await Task.detached(priority: .userInitiated) { Self.scanDirectorySync(url: scanURL, parent: nil, depth: 0, progress: progress, isTopLevel: true) }.value progressTask?.cancel() progressTask = nil if let item = result { // Sort children by size for better visualization item.children.sort { $0.size > $1.size } rootItem = item scanProgress = 1.0 // Check if we got limited results if item.children.isEmpty { error = "No accessible folders found. Check Full Disk Access." } else if item.children.count == 1 { error = "Limited access. Only \(item.children.first?.name ?? "one folder") accessible." } } else { error = "Could not scan directory. Check permissions." } isScanning = false } /// Scan a directory recursively (runs on background thread) /// Static and nonisolated to avoid actor isolation issues private nonisolated static func scanDirectorySync( url: URL, parent: FileItem?, depth: Int, progress: ScanProgress, isTopLevel: Bool = false ) -> FileItem? { let fileManager = FileManager.default let item = FileItem(url: url, isDirectory: true, parent: parent) progress.update(path: url.path) // Get directory contents let contents: [URL] do { contents = try fileManager.contentsOfDirectory( at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .totalFileSizeKey], options: [] // Don't skip hidden files to get accurate sizes ) } catch { // Can't access directory - return empty item with zero size return item } // Set total for progress tracking at top level if isTopLevel { progress.setTotalTopLevel(contents.count) } var children: [FileItem] = [] var totalSize: Int64 = 0 for contentURL in contents { do { let resourceValues = try contentURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .totalFileSizeKey]) let isDir = resourceValues.isDirectory ?? false if isDir { // Recursively scan subdirectory (limit depth to prevent hanging) if depth < 10 { if let childItem = scanDirectorySync(url: contentURL, parent: item, depth: depth + 1, progress: progress) { children.append(childItem) totalSize += childItem.size } } else { // For deep directories, just estimate size without full recursion let estimatedSize = quickDirectorySize(url: contentURL) let childItem = FileItem(url: contentURL, isDirectory: true, size: estimatedSize, parent: item) children.append(childItem) totalSize += estimatedSize } } else { // Regular file let fileSize = Int64(resourceValues.totalFileSize ?? resourceValues.fileSize ?? 0) let childItem = FileItem(url: contentURL, isDirectory: false, size: fileSize, parent: item) children.append(childItem) totalSize += fileSize } } catch { // Skip files we can't access continue } // Update progress for top-level items if isTopLevel { progress.completeTopLevel() } } // Sort children by size (largest first) for better visualization children.sort { $0.size > $1.size } item.children = children item.size = totalSize return item } /// Quick size estimation for deep directories private nonisolated static func quickDirectorySize(url: URL) -> Int64 { let fileManager = FileManager.default var totalSize: Int64 = 0 guard let enumerator = fileManager.enumerator( at: url, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { return 0 } while let fileURL = enumerator.nextObject() as? URL { do { let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) totalSize += Int64(resourceValues.fileSize ?? 0) } catch { continue } } return totalSize } /// Get size of a single file or directory (without full recursive scan) nonisolated func getQuickSize(url: URL) -> Int64 { let fileManager = FileManager.default var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return 0 } if !isDirectory.boolValue { do { let attrs = try fileManager.attributesOfItem(atPath: url.path) return attrs[.size] as? Int64 ?? 0 } catch { return 0 } } // For directories, use a quick enumeration var totalSize: Int64 = 0 if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) { while let fileURL = enumerator.nextObject() as? URL { do { let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) totalSize += Int64(resourceValues.fileSize ?? 0) } catch { continue } } } return totalSize } /// Cancel ongoing scan func cancel() { progressTask?.cancel() isScanning = false } /// Clear results func reset() { rootItem = nil isScanning = false scanProgress = 0 currentPath = "" scannedCount = 0 error = nil } }