Mac-utlity-app / MUA / Services / DiskAnalyzer.swift Blame
275 lines
6dacfa4 Mitchel Jan 17, 2026
//
//  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<Void, Never>?
    /// 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
    }
}