Internal tooling for Mac utility for storage management.
Swift
98.7%
JSON
1.1%
Markdown
0.2%
//
// 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