import Foundation actor ScanEngine { private let fileManager = FileManager.default private let home = FileManager.default.homeDirectoryForCurrentUser.path /// Live path reporter for the dashboard's scanning ticker. Throttled so /// a directory with thousands of entries doesn't flood the main actor. private var onPath: (@Sendable (String) -> Void)? private var lastReport = Date.distantPast /// `path` is an autoclosure so the String is only materialized after the /// throttle gate passes — a deep home-directory walk enumerates hundreds /// of thousands of entries and only ~12/sec are ever displayed. private func report(_ path: @autoclosure () -> String) { guard let onPath else { return } let now = Date() guard now.timeIntervalSince(lastReport) > 0.1 else { return } lastReport = now onPath(path()) } private struct CleanupTarget { let name: String let path: String let isSelected: Bool let minimumSize: Int64 init(name: String, path: String, isSelected: Bool = true, minimumSize: Int64 = 1024) { self.name = name self.path = path self.isSelected = isSelected self.minimumSize = minimumSize } } // MARK: - Public API func scanCategory( _ category: CleaningCategory, onPath: (@Sendable (String) -> Void)? = nil ) async -> CategoryResult { self.onPath = onPath defer { self.onPath = nil } switch category { case .smartScan: return CategoryResult(category: category, items: [], totalSize: 0) case .systemJunk: return scanSystemJunk() case .userCache: return scanUserCache() case .aiApps: return scanAIApps() case .mailAttachments: return scanMailAttachments() case .trashBins: return scanTrash() case .largeFiles: return scanLargeFiles() case .purgeableSpace: return scanPurgeableSpace() case .xcodeJunk: return scanXcodeJunk() case .brewCache: return scanBrewCache() case .nodeCache: return scanNodeCache() case .dockerCache: return scanDockerCache() } } func getDiskInfo() -> DiskInfo { var info = DiskInfo() do { let attrs = try fileManager.attributesOfFileSystem(forPath: "/") if let total = attrs[.systemSize] as? Int64 { info.totalSpace = total } if let free = attrs[.systemFreeSize] as? Int64 { info.freeSpace = free } info.usedSpace = info.totalSpace - info.freeSpace // Use URLResourceValues for accurate purgeable space detection let rootURL = URL(fileURLWithPath: "/") let values = try rootURL.resourceValues(forKeys: [ .volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey ]) if let importantCapacity = values.volumeAvailableCapacityForImportantUsage, let freeCapacity = values.volumeAvailableCapacity { // Purgeable = important capacity (free + purgeable) minus actual free let purgeable = importantCapacity - Int64(freeCapacity) if purgeable > 10 * 1024 * 1024 { // Only report if > 10 MB info.purgeableSpace = purgeable } } } catch { Logger.shared.log("Disk info unavailable: \(error.localizedDescription)", level: .warning) } return info } // MARK: - Scanners private func scanSystemJunk() -> CategoryResult { var items: [CleanableItem] = [] var totalSize: Int64 = 0 let systemPaths = [ "/Library/Caches", "/Library/Logs", "/private/var/log", "\(home)/Library/Logs", "/tmp", "/private/var/tmp", ] for path in systemPaths { let scanned = scanDirectory(path: path, category: .systemJunk, recursive: true, maxDepth: 3) items.append(contentsOf: scanned) } totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .systemJunk, items: items, totalSize: totalSize) } private func scanUserCache() -> CategoryResult { var items: [CleanableItem] = [] // Exclude cache roots claimed by dedicated categories to avoid double-counting. let excludedRootPaths = Set([ "\(home)/Library/Caches/Homebrew", "\(home)/Library/Caches/com.electron.ollama", "\(home)/Library/Caches/ollama", ].map(normalizePath)) // Dynamically enumerate ~/Library/Caches/ so every subdirectory is visible let cachePath = "\(home)/Library/Caches" let scanned = scanDirectory( path: cachePath, category: .userCache, recursive: false, maxDepth: 1, excluding: excludedRootPaths ) items.append(contentsOf: scanned) // Also scan for npm/pip/yarn caches let devCaches = [ "\(home)/.npm/_cacache", "\(home)/.cache/pip", "\(home)/.cache/yarn", "\(home)/.cache/pnpm", "\(home)/Library/Caches/pip", ] for path in devCaches { if let item = makeCleanupItem( name: URL(fileURLWithPath: path).lastPathComponent, path: path, category: .userCache ) { items.append(item) } } let uniqueItems = deduplicatedItems(items) let totalSize = uniqueItems.reduce(0) { $0 + $1.size } return CategoryResult(category: .userCache, items: uniqueItems, totalSize: totalSize) } private func scanAIApps() -> CategoryResult { let targets = [ CleanupTarget( name: "Ollama Logs", path: "\(home)/.ollama/logs" ), CleanupTarget( name: "Ollama Cache", path: "\(home)/Library/Caches/ollama" ), CleanupTarget( name: "Ollama Electron Cache", path: "\(home)/Library/Caches/com.electron.ollama" ), CleanupTarget( name: "Ollama WebKit Data", path: "\(home)/Library/WebKit/com.electron.ollama" ), CleanupTarget( name: "Ollama Saved State", path: "\(home)/Library/Saved Application State/com.electron.ollama.savedState" ), CleanupTarget( name: "Ollama CLI Prompt History (Optional)", path: "\(home)/.ollama/history", isSelected: false, minimumSize: 0 ), CleanupTarget( name: "LM Studio Server Logs", path: "\(home)/.lmstudio/server-logs" ), CleanupTarget( name: "LM Studio Conversations (Optional)", path: "\(home)/.lmstudio/conversations", isSelected: false, minimumSize: 0 ), ] let items = deduplicatedItems(targets.compactMap { target in makeCleanupItem( name: target.name, path: target.path, category: .aiApps, isSelected: target.isSelected, minimumSize: target.minimumSize ) }) let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .aiApps, items: items.sorted { $0.size > $1.size }, totalSize: totalSize) } private func scanMailAttachments() -> CategoryResult { var items: [CleanableItem] = [] let mailPaths = [ "\(home)/Library/Mail Downloads", "\(home)/Library/Containers/com.apple.mail/Data/Library/Mail Downloads", ] for path in mailPaths { let scanned = scanDirectory(path: path, category: .mailAttachments, recursive: true, maxDepth: 3) items.append(contentsOf: scanned) } let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .mailAttachments, items: items, totalSize: totalSize) } private func scanTrash() -> CategoryResult { var items: [CleanableItem] = [] let trashPath = "\(home)/.Trash" let scanned = scanDirectory(path: trashPath, category: .trashBins, recursive: false, maxDepth: 1) items.append(contentsOf: scanned) let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .trashBins, items: items, totalSize: totalSize) } private func scanLargeFiles() -> CategoryResult { var items: [CleanableItem] = [] // Honor the thresholds the user set in Settings → Cleaning. These keys // were previously surfaced in the UI but never read here, so the sliders // did nothing; defaults match the old hardcoded 100 MB / 12-month values. let defaults = UserDefaults.standard let thresholdMB = defaults.object(forKey: "settings.cleaning.largeFileThreshold") as? Int ?? 100 let oldFileMonths = defaults.object(forKey: "settings.cleaning.oldFileMonths") as? Int ?? 12 let minSize = Int64(max(1, thresholdMB)) * 1024 * 1024 let oldCutoff = Calendar.current.date(byAdding: .month, value: -max(1, oldFileMonths), to: Date()) ?? Date.distantPast // Folders the user excluded from the large-file scan (issue #121) — // e.g. VM images, media libraries, project assets they never want // surfaced. Files anywhere inside an excluded folder are skipped, and // the directory subtree is pruned so we don't even walk it. let excludedFolders = (defaults.stringArray(forKey: "settings.cleaning.largeFileExcludedFolders") ?? []) .map(normalizePath) .filter { !$0.isEmpty } // Honor the "Skip hidden files during scan" toggle, which was a dead // control until now (no scanner read it). Default true preserves the // previous always-skip behavior. let skipHidden = defaults.object(forKey: "settings.cleaning.skipHiddenFiles") as? Bool ?? true var enumerationOptions: FileManager.DirectoryEnumerationOptions = [.skipsPackageDescendants] if skipHidden { enumerationOptions.insert(.skipsHiddenFiles) } func isExcluded(_ path: String) -> Bool { let normalized = normalizePath(path) return excludedFolders.contains { normalized == $0 || normalized.hasPrefix($0 + "/") } } let searchPaths = [ "\(home)/Downloads", "\(home)/Documents", "\(home)/Desktop", ] for basePath in searchPaths { guard let enumerator = fileManager.enumerator( at: URL(fileURLWithPath: basePath), includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey], options: enumerationOptions ) else { continue } // No entry cap. A 5k cap was here previously and meant any user // with hundreds of thousands of small files (e.g. node_modules // checkouts) would never see anything past entry 5k — the // scattered 100+ MB files were always past that bound. for case let fileURL as URL in enumerator { // Prune excluded subtrees: hitting the excluded directory itself // skips its whole contents; for files the call is a harmless no-op. // Skip the path normalization entirely when nothing is excluded. if !excludedFolders.isEmpty, isExcluded(fileURL.path) { enumerator.skipDescendants() continue } report(fileURL.path) guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]), let isFile = resourceValues.isRegularFile, isFile, let fileSize = resourceValues.fileSize else { continue } let size = Int64(fileSize) let modDate = resourceValues.contentModificationDate if size > minSize || (modDate != nil && modDate! < oldCutoff && size > 10 * 1024 * 1024) { items.append(CleanableItem( name: fileURL.lastPathComponent, path: fileURL.path, size: size, category: .largeFiles, isSelected: false, // Don't auto-select large files lastModified: modDate )) } } } items.sort { $0.size > $1.size } let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .largeFiles, items: items, totalSize: totalSize) } private func scanPurgeableSpace() -> CategoryResult { var items: [CleanableItem] = [] var totalSize: Int64 = 0 // Detect APFS purgeable space via URLResourceValues (no admin needed) let diskInfo = getDiskInfo() if diskInfo.purgeableSpace > 0 { // Purgeable space is reclaimed via `diskutil apfs purgePurgeable /` // (see CleaningEngine.purgePurgeableSpace), it is NOT a file that // gets unlinked. Using "/" as the path made the UI render the root // directory as the deletion target and triggered a bogus // "couldn't remove /" error after cleaning. Use an empty path so // the row shows no misleading filesystem location and the reveal- // in-Finder action stays disabled for this entry. (See issue #112.) items.append(CleanableItem( name: "APFS Purgeable Space", path: "", size: diskInfo.purgeableSpace, category: .purgeableSpace, isSelected: true, lastModified: nil )) totalSize = diskInfo.purgeableSpace } // Also list Time Machine local snapshots if any exist let snapshots = getLocalSnapshots() for snapshot in snapshots { let snapshotSize = snapshot.size > 0 ? snapshot.size : 0 if snapshotSize > 0 { items.append(CleanableItem( name: "TM Snapshot: \(snapshot.name)", path: snapshot.name, size: snapshotSize, category: .purgeableSpace, isSelected: false, lastModified: snapshot.date )) } } return CategoryResult(category: .purgeableSpace, items: items, totalSize: totalSize) } private func scanXcodeJunk() -> CategoryResult { var items: [CleanableItem] = [] let xcodePaths = [ "\(home)/Library/Developer/Xcode/DerivedData", "\(home)/Library/Developer/Xcode/Archives", "\(home)/Library/Developer/CoreSimulator/Caches", "\(home)/Library/Caches/com.apple.dt.Xcode", ] for path in xcodePaths { if fileManager.fileExists(atPath: path) { let size = directorySize(path: path) if size > 0 { items.append(CleanableItem( name: URL(fileURLWithPath: path).lastPathComponent, path: path, size: size, category: .xcodeJunk, isSelected: true, lastModified: nil )) } } } let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .xcodeJunk, items: items, totalSize: totalSize) } private func scanBrewCache() -> CategoryResult { var items: [CleanableItem] = [] // Default Homebrew download cache var brewCachePaths = [ "\(home)/Library/Caches/Homebrew", ] // Known-good Homebrew cache roots. Any path returned by `brew --cache` // that is NOT inside one of these is refused - prevents an attacker // setting HOMEBREW_CACHE=$HOME/Documents from steering our cleanup. let knownBrewRoots = [ "\(home)/Library/Caches/Homebrew", "/opt/homebrew/Library/Caches", "/usr/local/Homebrew/Library/Caches", "/Library/Caches/Homebrew", ] // Detect custom HOMEBREW_CACHE via `brew --cache`. Strip HOMEBREW_* // from the child env so an attacker can't steer the output via // launchctl setenv, then validate the output against knownBrewRoots. let brewBinPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] var detectedCustomCache = false for brewBin in brewBinPaths { guard fileManager.fileExists(atPath: brewBin) else { continue } let task = Process() task.executableURL = URL(fileURLWithPath: brewBin) task.arguments = ["--cache"] var sanitizedEnv = ProcessInfo.processInfo.environment for key in Array(sanitizedEnv.keys) where key.hasPrefix("HOMEBREW_") { sanitizedEnv.removeValue(forKey: key) } task.environment = sanitizedEnv let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !output.isEmpty { let normalized = normalizePath(output) let isKnown = knownBrewRoots.contains { root in normalized == root || normalized.hasPrefix(root + "/") } guard isKnown else { Logger.shared.log("Refusing suspicious brew cache path: \(output)", level: .warning) break } if !brewCachePaths.map(normalizePath).contains(normalized) { brewCachePaths.append(output) } detectedCustomCache = true } } catch { Logger.shared.log("Failed to run \(brewBin) --cache: \(error.localizedDescription)", level: .warning) } break // Only need the first available brew binary } if !detectedCustomCache { Logger.shared.log("Homebrew not found at standard paths; scanning default cache location only", level: .info) } for path in brewCachePaths { if fileManager.fileExists(atPath: path) { let size = directorySize(path: path) if size > 0 { items.append(CleanableItem( name: URL(fileURLWithPath: path).lastPathComponent, path: path, size: size, category: .brewCache, isSelected: true, lastModified: nil )) } } } let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .brewCache, items: items, totalSize: totalSize) } private func scanNodeCache() -> CategoryResult { // Each entry is `(displayName, defaultPath, optional CLI for cache-dir // detection)`. The CLI invocation overrides `defaultPath` if the user // has set a custom location (e.g. via `npm config set cache`). struct ManagerCache { let name: String let defaultPath: String let detectionCommand: (cli: String, args: [String])? } let managers: [ManagerCache] = [ ManagerCache( name: "npm cache", defaultPath: "\(home)/.npm", detectionCommand: (cli: "npm", args: ["config", "get", "cache"]) ), ManagerCache( name: "yarn classic cache", defaultPath: "\(home)/Library/Caches/Yarn", detectionCommand: (cli: "yarn", args: ["cache", "dir"]) ), // Yarn Berry / v2+ uses a per-project .yarn/cache. We don't try to // chase those — they're inside user projects and shouldn't be // touched by a system cleaner. The classic cache above remains the // global, safe-to-clean location. ManagerCache( name: "pnpm content-addressable store", defaultPath: "\(home)/Library/pnpm/store", detectionCommand: (cli: "pnpm", args: ["store", "path"]) ), ] var items: [CleanableItem] = [] // Common $PATH locations on macOS where these CLIs land. let cliSearchPaths = [ "/opt/homebrew/bin", "/usr/local/bin", "\(home)/.local/bin", "\(home)/.volta/bin", "\(home)/.nvm/versions/node", ] for manager in managers { var paths: [String] = [] paths.append(manager.defaultPath) if let cmd = manager.detectionCommand, let cliPath = locateExecutable(named: cmd.cli, searchPaths: cliSearchPaths), let detected = runCommandReadingStdout(executable: cliPath, args: cmd.args) { let normalized = detected.trimmingCharacters(in: .whitespacesAndNewlines) if !normalized.isEmpty, !paths.map(normalizePath).contains(normalizePath(normalized)) { paths.append(normalized) } } for path in paths { guard fileManager.fileExists(atPath: path) else { continue } let size = directorySize(path: path) guard size > 0 else { continue } items.append(CleanableItem( name: manager.name, path: path, size: size, category: .nodeCache, isSelected: true, lastModified: nil )) } } let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .nodeCache, items: items, totalSize: totalSize) } // -- Process helpers (used by scanNodeCache) -- private func locateExecutable(named name: String, searchPaths: [String]) -> String? { for dir in searchPaths { let candidate = (dir as NSString).appendingPathComponent(name) if fileManager.isExecutableFile(atPath: candidate) { return candidate } // For nvm: ~/.nvm/versions/node//bin/ if dir.hasSuffix("/.nvm/versions/node"), let versions = try? fileManager.contentsOfDirectory(atPath: dir) { for v in versions { let nested = (dir as NSString).appendingPathComponent("\(v)/bin/\(name)") if fileManager.isExecutableFile(atPath: nested) { return nested } } } } return nil } private func runCommandReadingStdout(executable: String, args: [String]) -> String? { let task = Process() task.executableURL = URL(fileURLWithPath: executable) task.arguments = args let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() } catch { Logger.shared.log("\(executable) \(args.joined(separator: " ")) failed: \(error.localizedDescription)", level: .warning) return nil } guard task.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) } private func scanDockerCache() -> CategoryResult { var items: [CleanableItem] = [] // Docker Desktop on macOS keeps its VM disk + caches under // ~/Library/Containers/com.docker.docker/Data. The caches we // surface here are *recoverable* — they will be regenerated by // Docker on next pull/build, and `docker system prune` is the // CLI equivalent of cleaning them. let dockerDataDirs = [ // Build cache (BuildKit), per-user "\(home)/Library/Containers/com.docker.docker/Data/cache", // Vmnetd / vpnkit log + telemetry caches "\(home)/Library/Containers/com.docker.docker/Data/log", "\(home)/Library/Containers/com.docker.docker/Data/tmp", // Group containers caches (Docker Desktop helper apps) "\(home)/Library/Group Containers/group.com.docker/Caches", // CLI plugin download cache "\(home)/.docker/cli-plugins/.cache", // Buildx / containerd inline cache "\(home)/.docker/buildx/cache", ] for path in dockerDataDirs { guard fileManager.fileExists(atPath: path) else { continue } let size = directorySize(path: path) guard size > 0 else { continue } items.append(CleanableItem( name: URL(fileURLWithPath: path).lastPathComponent, path: path, size: size, category: .dockerCache, isSelected: true, lastModified: nil )) } // If the `docker` CLI is available, surface reclaimable space // reported by `docker system df` as a single virtual entry. // We don't try to delete it directly — the user runs // `docker system prune` themselves, which is the safe path. // We just show how much they can recover. let dockerBinPaths = ["/usr/local/bin/docker", "/opt/homebrew/bin/docker"] for dockerBin in dockerBinPaths where fileManager.fileExists(atPath: dockerBin) { if let reclaimable = reclaimableDockerSpace(dockerBin: dockerBin), reclaimable > 0 { items.append(CleanableItem( name: "Reclaimable (run `docker system prune -af`)", path: dockerBin, size: reclaimable, category: .dockerCache, isSelected: false, lastModified: nil )) } break } let totalSize = items.reduce(0) { $0 + $1.size } return CategoryResult(category: .dockerCache, items: items, totalSize: totalSize) } /// Sum the reclaimable bytes reported by `docker system df --format json`. /// Returns nil when Docker isn't running or the command fails — callers /// should treat that as "no reclaimable info available", not as an error. private func reclaimableDockerSpace(dockerBin: String) -> Int64? { let task = Process() task.executableURL = URL(fileURLWithPath: dockerBin) task.arguments = ["system", "df", "--format", "{{.Reclaimable}}"] let stdoutPipe = Pipe() task.standardOutput = stdoutPipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() } catch { Logger.shared.log("docker system df failed: \(error.localizedDescription)", level: .warning) return nil } guard task.terminationStatus == 0 else { return nil } let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { return nil } // Each line looks like e.g. "1.234GB (45%)" — parse the leading number. var total: Int64 = 0 for line in output.split(separator: "\n") { let raw = line.split(separator: " ").first.map(String.init) ?? "" if let bytes = parseHumanBytes(raw) { total += bytes } } return total } /// Parse Docker's compact size format ("1.23GB", "456MB", "789kB") into bytes. private func parseHumanBytes(_ s: String) -> Int64? { let trimmed = s.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } let units: [(String, Double)] = [ ("TB", 1_000_000_000_000), ("GB", 1_000_000_000), ("MB", 1_000_000), ("kB", 1_000), ("KB", 1_000), ("B", 1), ] for (suffix, multiplier) in units { if trimmed.hasSuffix(suffix) { let numberPart = String(trimmed.dropLast(suffix.count)) if let value = Double(numberPart) { return Int64(value * multiplier) } } } return nil } // MARK: - Helpers private func scanDirectory( path: String, category: CleaningCategory, recursive: Bool, maxDepth: Int, excluding excludedPaths: Set = [] ) -> [CleanableItem] { var items: [CleanableItem] = [] guard fileManager.fileExists(atPath: path), fileManager.isReadableFile(atPath: path) else { return [] } do { let contents = try fileManager.contentsOfDirectory(atPath: path) for item in contents { let fullPath = (path as NSString).appendingPathComponent(item) report(fullPath) if excludedPaths.contains(normalizePath(fullPath)) { continue } var isDir: ObjCBool = false guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } // Security: skip symlinks to prevent symlink-following attacks if let attrs = try? fileManager.attributesOfItem(atPath: fullPath), let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSymbolicLink { continue } if isDir.boolValue { let size = directorySize(path: fullPath) if size > 1024 { // Skip tiny entries items.append(CleanableItem( name: item, path: fullPath, size: size, category: category, isSelected: true, lastModified: fileModDate(path: fullPath) )) } } else { if let attrs = try? fileManager.attributesOfItem(atPath: fullPath), let size = attrs[.size] as? Int64, size > 1024 { items.append(CleanableItem( name: item, path: fullPath, size: size, category: category, isSelected: true, lastModified: attrs[.modificationDate] as? Date )) } } } } catch { Logger.shared.log("Cannot enumerate \(path): \(error.localizedDescription)", level: .warning) } return items } private func makeCleanupItem( name: String, path: String, category: CleaningCategory, isSelected: Bool = true, minimumSize: Int64 = 1024 ) -> CleanableItem? { report(path) var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), fileManager.isReadableFile(atPath: path) else { return nil } if isDirectory.boolValue { let size = directorySize(path: path) guard size > minimumSize else { return nil } return CleanableItem( name: name, path: path, size: size, category: category, isSelected: isSelected, lastModified: fileModDate(path: path) ) } guard let attrs = try? fileManager.attributesOfItem(atPath: path), let size = attrs[.size] as? Int64, size > minimumSize else { return nil } return CleanableItem( name: name, path: path, size: size, category: category, isSelected: isSelected, lastModified: attrs[.modificationDate] as? Date ) } private func deduplicatedItems(_ items: [CleanableItem]) -> [CleanableItem] { var seenPaths: Set = [] var uniqueItems: [CleanableItem] = [] for item in items { let normalizedPath = normalizePath(item.path) if seenPaths.insert(normalizedPath).inserted { uniqueItems.append(item) } } return uniqueItems } private func normalizePath(_ path: String) -> String { (path as NSString).standardizingPath } private func directorySize(path: String) -> Int64 { var totalSize: Int64 = 0 guard let enumerator = fileManager.enumerator( at: URL(fileURLWithPath: path), includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey], options: [.skipsHiddenFiles] ) else { return 0 } var count = 0 for case let fileURL as URL in enumerator { count += 1 if count > 10000 { break } // Safety limit for very large directories report(fileURL.path) guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), let isFile = values.isRegularFile, isFile, let size = values.fileSize else { continue } totalSize += Int64(size) } return totalSize } private func fileModDate(path: String) -> Date? { try? fileManager.attributesOfItem(atPath: path)[.modificationDate] as? Date } // MARK: - Purgeable Space Helpers struct SnapshotInfo { let name: String let size: Int64 let date: Date? } /// Get local Time Machine snapshots and their sizes private func getLocalSnapshots() -> [SnapshotInfo] { var snapshots: [SnapshotInfo] = [] let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") task.arguments = ["listlocalsnapshots", "/"] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { return [] } // Parse snapshot names (format: com.apple.TimeMachine.2026-04-08-123456.local) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd-HHmmss" for line in output.components(separatedBy: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, trimmed.contains("TimeMachine") else { continue } // Extract date from snapshot name var snapshotDate: Date? let parts = trimmed.components(separatedBy: ".") for part in parts { if let date = dateFormatter.date(from: part) { snapshotDate = date break } } // Get snapshot size via tmutil let sizeBytes = getSnapshotSize(name: trimmed) if sizeBytes > 0 { snapshots.append(SnapshotInfo( name: trimmed, size: sizeBytes, date: snapshotDate )) } } } catch { Logger.shared.log("tmutil listlocalsnapshots failed: \(error.localizedDescription)", level: .info) } return snapshots } /// Get size of a specific local snapshot via APFS snapshot listing private func getSnapshotSize(name: String) -> Int64 { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") task.arguments = ["apfs", "listSnapshots", "/", "-plist"] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], let snapshots = plist["Snapshots"] as? [[String: Any]] else { Logger.shared.log("Could not parse APFS snapshot plist for \(name)", level: .info) return 0 } for snapshot in snapshots { if let snapshotName = snapshot["SnapshotName"] as? String, snapshotName == name, let dataSize = snapshot["DataSize"] as? Int64 { return dataSize } } Logger.shared.log("Snapshot \(name) not found in APFS listing", level: .info) } catch { Logger.shared.log("diskutil apfs listSnapshots failed: \(error.localizedDescription)", level: .warning) } return 0 } /// Calculate total local snapshot size from disk usage difference private func getLocalSnapshotSize() -> Int64 { // The difference between "Volume Used Space" visible to the filesystem // and actual container usage can indicate snapshot overhead. // However, without root access, we can only check if tmutil reports snapshots. let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") task.arguments = ["listlocalsnapshots", "/"] let pipe = Pipe() task.standardOutput = pipe task.standardError = Pipe() do { try task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { return 0 } let snapshotCount = output.components(separatedBy: "\n") .filter { $0.contains("TimeMachine") || $0.contains("com.apple") } .count if snapshotCount == 0 { return 0 } // Check if system reports purgeable via newer diskutil // On systems that support it, "Purgeable Space" appears in diskutil info let diskTask = Process() diskTask.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") diskTask.arguments = ["info", "-plist", "/"] let diskPipe = Pipe() diskTask.standardOutput = diskPipe diskTask.standardError = Pipe() try diskTask.run() diskTask.waitUntilExit() let diskData = diskPipe.fileHandleForReading.readDataToEndOfFile() if let plist = try? PropertyListSerialization.propertyList(from: diskData, format: nil) as? [String: Any], let purgeable = plist["APFSContainerFree"] as? Int64, let volumeFree = plist["FreeSpace"] as? Int64 { // Purgeable is roughly the difference (snapshots that can be freed) let purgeableEstimate = max(0, volumeFree - purgeable) if purgeableEstimate > 10 * 1024 * 1024 { // Only report if > 10 MB return purgeableEstimate } } } catch { Logger.shared.log("Purgeable space detection failed: \(error.localizedDescription)", level: .warning) } return 0 } }