1028 lines
40 KiB
Swift
1028 lines
40 KiB
Swift
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/<version>/bin/<name>
|
|
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<String> = []
|
|
) -> [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<String> = []
|
|
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
|
|
}
|
|
}
|