Internal tooling for Mac utility for storage management.

Swift 98.7% JSON 1.1% Markdown 0.2%
DiskAnalyzer.swift 275 lines (9 KB)
//
//  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
    }
}

About

Internal tooling for Mac utility for storage management.

0 stars
0 forks