From b13162942f360e24eeafed86b815037cc42c5a51 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 13:24:30 +0530 Subject: [PATCH 1/2] Clean Drive: list developer junk directory by directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean Drive's system categories are a checkbox each: tick "Cache files" and everything under ~/Library/Caches goes, sight unseen. That is the wrong shape for a developer's disk, where the reclaimable space is concentrated in a handful of enormous, individually meaningful directories — one abandoned project's node_modules, last year's DerivedData, the device support for a phone sold two years ago. A developer wants to keep this one and drop that one, and a single checkbox cannot express it. So developer junk gets its own sheet, where every category expands into the individual directories that make up its total and each can be unticked on its own. Categories start with a sensible default and nothing moves until the user accepts a confirmation naming the count and the total. The safety argument is the reason the matching lives in pure helpers rather than inline in the scan. A cleaner that mistakes a source tree for a cache is not a bug you get to fix in the next release, so eligibility is decided in exactly one place — `DevJunkCategoryID.accepts` — and that gate is applied twice: when the scanner discovers a path, and again immediately before the delete, because entries survive across rescans and across the confirmation alert. Containment is component-wise, never a string prefix: `hasPrefix` would happily place ~/Code/MyApp-backup inside ~/Code/MyApp, which is exactly how this class of tool eats someone's work. The two categories that live in the user's own folders are the conservative case. A directory named node_modules only counts when a package.json sits beside it, and a .build only when there is a Package.swift; a folder someone named node_modules to hold notes is never touched, and neither is a .build belonging to some other build system. The walk is depth-capped, confined to the usual project roots, and prunes package and VCS stores so a nested copy is not offered twice. Deliberate omissions are documented where they are made: CoreSimulator/Devices holds installed simulator apps rather than caches, Docker's Data/vms holds every image and volume the user owns, ~/.gradle/wrapper holds checksum-pinned distributions, and ~/.cocoapods/repos may be a private spec repo that cannot be re-cloned. Deletion goes to the Trash rather than an outright unlink, the reverse of what the system categories do. Those sweep caches an app will regenerate in seconds; these are the user's project folders, so if the heuristics are ever wrong the mistake has to be undoable — and the sheet says plainly that the space comes back only once the Trash is emptied. Sizing runs off the main actor and streams a category at a time, so the list fills in progressively instead of waiting for the slowest tree, and the walk is cancellable: a node_modules sweep left running for a closed panel would keep a core busy for nothing. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/CleanDrive.swift | 45 +- Sources/DMonteCore/CleanDriveDevJunk.swift | 826 ++++++++++++++++++ .../DMonteCore/CleanDriveDevJunkView.swift | 421 +++++++++ Sources/DMonteCore/CleanDriveSizing.swift | 12 + .../CleanDriveDevJunkTests.swift | 517 +++++++++++ .../SettingsOverlaySizingTests.swift | 21 +- 6 files changed, 1833 insertions(+), 9 deletions(-) create mode 100644 Sources/DMonteCore/CleanDriveDevJunk.swift create mode 100644 Sources/DMonteCore/CleanDriveDevJunkView.swift create mode 100644 Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift diff --git a/Sources/DMonteCore/CleanDrive.swift b/Sources/DMonteCore/CleanDrive.swift index 75a2e1f..8b0f6d2 100644 --- a/Sources/DMonteCore/CleanDrive.swift +++ b/Sources/DMonteCore/CleanDrive.swift @@ -364,6 +364,7 @@ public struct CleanDriveWindowView: View { /// instant delete shows a visible sweep. Smaller moves scale down proportionally. private static let fullDrainSeconds: Double = 1.1 @State private var isShowingSettings = false + @State private var isShowingDevJunk = false private let layout = CleanDriveLayout.current public init(onQuit: @escaping () -> Void) { @@ -394,6 +395,12 @@ public struct CleanDriveWindowView: View { ) } } + + if isShowingDevJunk { + PreferencesOverlay(cornerRadius: 18) { + CleanDriveDevJunkPanel(onClose: { isShowingDevJunk = false }) + } + } } .frame(width: layout.windowSize.width, height: layout.windowSize.height) .frostedPanel(cornerRadius: 18) @@ -538,16 +545,37 @@ public struct CleanDriveWindowView: View { .padding(.top, layout.cleanButtonTopPadding) } + /// Both secondary actions share one row. Giving developer junk its own line would push the + /// window past its fixed height at scale 1.0, and the panel cannot grow — it is sized by + /// `CleanDriveSizing.preferredSize()`. private var storageButton: some View { - Button { - NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Disk Utility.app")) - } label: { - Text("Manage Storage...") - .font(.system(size: layout.storageFontSize, weight: .medium)) - .underline() - .foregroundStyle(Color.accentColor) + HStack(spacing: layout.footerLinkSpacing) { + Spacer() + + Button { + isShowingDevJunk = true + } label: { + Text("Developer Junk...") + .font(.system(size: layout.storageFontSize, weight: .medium)) + .underline() + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .disabled(isCleaning) + .help("Xcode, npm, Homebrew and other developer caches, listed directory by directory") + + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Disk Utility.app")) + } label: { + Text("Manage Storage...") + .font(.system(size: layout.storageFontSize, weight: .medium)) + .underline() + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + + Spacer() } - .buttonStyle(.plain) } private var selectedSize: UInt64 { @@ -785,6 +813,7 @@ private struct CleanDriveLayout { var cleanButtonTopPadding: CGFloat { 4 * scale } var buttonCornerRadius: CGFloat { 7 * scale } var storageFontSize: CGFloat { 12 * scale } + var footerLinkSpacing: CGFloat { 14 * scale } } private struct CleanDriveSettingsView: View { diff --git a/Sources/DMonteCore/CleanDriveDevJunk.swift b/Sources/DMonteCore/CleanDriveDevJunk.swift new file mode 100644 index 0000000..989fc64 --- /dev/null +++ b/Sources/DMonteCore/CleanDriveDevJunk.swift @@ -0,0 +1,826 @@ +import Foundation + +/// The developer-detritus categories Clean Drive can reclaim. These are deliberately kept apart +/// from `CleanDriveCategoryID`: the system categories sweep whole well-known cache roots, whereas +/// developer junk is enumerated entry by entry so the user can drill in and keep individual +/// projects. Every category here regenerates — the worst case for deleting one is a rebuild or a +/// re-download, never lost work — which is the bar a category must clear to be listed at all. +public enum DevJunkCategoryID: String, CaseIterable, Identifiable, Sendable { + case xcodeDerivedData + case xcodeArchives + case deviceSupport + case simulatorCaches + case nodeModules + case swiftPackageBuilds + case cocoaPodsCache + case jvmCaches + case scriptingCaches + case homebrewCache + case dockerLeftovers + + public var id: String { rawValue } + + public var title: String { + switch self { + case .xcodeDerivedData: "Xcode DerivedData" + case .xcodeArchives: "Xcode Archives" + case .deviceSupport: "Device Support" + case .simulatorCaches: "Simulator caches" + case .nodeModules: "node_modules" + case .swiftPackageBuilds: "SwiftPM .build" + case .cocoaPodsCache: "CocoaPods cache" + case .jvmCaches: "Gradle / Maven caches" + case .scriptingCaches: "pip / npm / yarn caches" + case .homebrewCache: "Homebrew cache" + case .dockerLeftovers: "Docker leftovers" + } + } + + /// The honest one-liner shown under the title. It always says what it costs to delete the + /// entries, because "reclaimable" is not the same as "free" — a node_modules tree costs an + /// `npm install` to get back, and on a slow connection that is a real price. + public var detail: String { + switch self { + case .xcodeDerivedData: + "Build intermediates and indexes. Xcode rebuilds them on the next build." + case .xcodeArchives: + "Shipped-build archives with their dSYMs. Deleting them means old crash reports can no longer be symbolicated." + case .deviceSupport: + "Symbols copied from devices you have attached. Re-copied the next time you plug the device in." + case .simulatorCaches: + "Simulator caches and logs. Simulator devices and their installed apps are never touched." + case .nodeModules: + "Installed npm dependencies. Restored by running npm install again in that project." + case .swiftPackageBuilds: + "SwiftPM build products and checkouts. Restored by the next swift build." + case .cocoaPodsCache: + "Downloaded pod archives. Re-fetched by the next pod install." + case .jvmCaches: + "Downloaded Gradle and Maven artifacts. Re-fetched by the next build." + case .scriptingCaches: + "Download caches for pip, npm and yarn. Installed packages are not touched." + case .homebrewCache: + "Downloaded bottles and installers. Installed formulae are not touched." + case .dockerLeftovers: + "Docker Desktop logs. Images, containers and volumes are never touched." + } + } + + /// Archives are the one category that loses something you cannot regenerate — the dSYMs that + /// make an old crash report readable — so it starts unchecked even though it is usually the + /// biggest single win on a shipping developer's disk. + public var isSelectedByDefault: Bool { + self != .xcodeArchives + } + + /// Fixed, well-known directories owned by a toolchain. Nothing is inferred here: if a path is + /// not spelled out in this table it can never be listed, let alone deleted. + var fixedTargets: [DevJunkTarget] { + switch self { + case .xcodeDerivedData: + [DevJunkTarget("~/Library/Developer/Xcode/DerivedData", granularity: .children)] + case .xcodeArchives: + [DevJunkTarget("~/Library/Developer/Xcode/Archives", granularity: .children)] + case .deviceSupport: + [ + DevJunkTarget("~/Library/Developer/Xcode/iOS DeviceSupport", granularity: .children), + DevJunkTarget("~/Library/Developer/Xcode/watchOS DeviceSupport", granularity: .children), + DevJunkTarget("~/Library/Developer/Xcode/tvOS DeviceSupport", granularity: .children), + DevJunkTarget("~/Library/Developer/Xcode/visionOS DeviceSupport", granularity: .children) + ] + case .simulatorCaches: + // Deliberately excludes ~/Library/Developer/CoreSimulator/Devices: those are the + // simulators themselves, complete with installed apps and their data, and wiping + // them is a destructive act rather than a cache eviction. + [ + DevJunkTarget("~/Library/Developer/CoreSimulator/Caches", granularity: .children), + DevJunkTarget("~/Library/Logs/CoreSimulator", granularity: .children) + ] + case .nodeModules, .swiftPackageBuilds: + [] + case .cocoaPodsCache: + // ~/.cocoapods/repos is left alone on purpose: it is a git checkout the user may have + // pointed at a private spec repo, and re-cloning it is neither cheap nor always possible. + [DevJunkTarget("~/Library/Caches/CocoaPods", granularity: .children)] + case .jvmCaches: + // ~/.gradle/wrapper holds the Gradle distributions a project pins by checksum, and + // ~/.m2 outside repository/ holds settings.xml, so both stay out of range. + [ + DevJunkTarget("~/.gradle/caches", granularity: .children), + DevJunkTarget("~/.m2/repository", granularity: .children) + ] + case .scriptingCaches: + [ + DevJunkTarget("~/Library/Caches/pip", granularity: .children), + DevJunkTarget("~/.cache/pip", granularity: .children), + DevJunkTarget("~/.npm/_cacache", granularity: .wholeDirectory), + DevJunkTarget("~/Library/Caches/Yarn", granularity: .children), + DevJunkTarget("~/.cache/yarn", granularity: .children), + DevJunkTarget("~/.yarn/berry/cache", granularity: .wholeDirectory) + ] + case .homebrewCache: + [ + DevJunkTarget("~/Library/Caches/Homebrew", granularity: .children) + ] + case .dockerLeftovers: + // Only logs. The Docker.raw disk image under Data/vms holds every image, container and + // volume the user has, so the container root is never a target — only its log folder. + [ + DevJunkTarget("~/Library/Containers/com.docker.docker/Data/log", granularity: .children), + DevJunkTarget("~/Library/Group Containers/group.com.docker/log", granularity: .children) + ] + } + } + + /// Set for the two categories that live in the user's own project folders rather than at a + /// fixed path, and therefore have to be found by walking. Nothing is matched on name alone — + /// see `DevJunkProjectScan.requiredSibling`. + var projectScan: DevJunkProjectScan? { + switch self { + case .nodeModules: + DevJunkProjectScan(directoryName: "node_modules", requiredSibling: "package.json") + case .swiftPackageBuilds: + DevJunkProjectScan(directoryName: ".build", requiredSibling: "Package.swift") + default: + nil + } + } + + /// Every directory tree this category is allowed to touch, expanded against `home`. A path + /// that is not inside one of these can never belong to the category, whatever it is named. + public func allowedRoots(home: String) -> [String] { + if projectScan != nil { + return DevJunkPaths.projectSearchRoots(home: home) + } + + return fixedTargets.map { DevJunkPaths.expand($0.path, home: home) } + } + + /// The single gate every candidate must pass before it can be listed in the UI or handed to + /// the remover. It is intentionally the *only* way a path becomes eligible, and it is applied + /// twice: once when the scanner discovers the path and again immediately before deletion, so a + /// stale entry from a previous scan cannot be used to delete something that is now a source + /// directory. + public func accepts(path: String, home: String) -> Bool { + let normalized = DevJunkPaths.normalize(DevJunkPaths.expand(path, home: home)) + let components = DevJunkPaths.components(normalized) + + guard !components.isEmpty else { + return false + } + + // Nothing under a denied root is eligible unless this category's own target table spells + // the path out. The Xcode and CoreSimulator caches genuinely live inside ~/Library, but a + // directory merely *discovered* by walking never may. This is the backstop, not the + // primary check: the granularity rules below still have to agree. + let isUnderOwnTarget = fixedTargets.contains { target in + DevJunkPaths.isDescendantOrSelf(normalized, of: DevJunkPaths.expand(target.path, home: home)) + } + if !isUnderOwnTarget, + DevJunkPaths.deniedRoots(home: home).contains(where: { DevJunkPaths.isDescendantOrSelf(normalized, of: $0) }) { + return false + } + + if let scan = projectScan { + guard components.last == scan.directoryName else { + return false + } + + guard let root = DevJunkPaths.projectSearchRoots(home: home).first( + where: { DevJunkPaths.isStrictDescendant(normalized, of: $0) } + ) else { + return false + } + + // A node_modules inside another node_modules (or inside a .build, or a stray copy in a + // .git object store) is already covered by its ancestor and must not be listed twice. + // Only the components below the search root are examined: the components of the home + // directory itself are not the user's project structure and must not veto a match. + let ancestors = components.dropLast().dropFirst(DevJunkPaths.components(root).count) + return !ancestors.contains { DevJunkPaths.prunedDirectoryNames.contains($0) } + } + + return fixedTargets.contains { target in + let root = DevJunkPaths.expand(target.path, home: home) + return switch target.granularity { + case .children: + // Only the direct children of the root are entries; the root itself stays put so + // the toolchain does not have to recreate it. + DevJunkPaths.isDirectChild(normalized, of: root) + case .wholeDirectory: + DevJunkPaths.normalize(root) == normalized + } + } + } +} + +/// A fixed directory a category owns, plus whether the entries are the directory itself or its +/// immediate children. Children give the user something to drill into; `wholeDirectory` is for +/// caches whose internals are opaque hash buckets that nobody can meaningfully choose between. +struct DevJunkTarget: Sendable, Equatable { + enum Granularity: Sendable, Equatable { + case wholeDirectory + case children + } + + var path: String + var granularity: Granularity + + init(_ path: String, granularity: Granularity) { + self.path = path + self.granularity = granularity + } +} + +/// How a category that lives in the user's own project folders is discovered. The required sibling +/// is what keeps this conservative: a directory called `node_modules` only counts when its parent +/// also contains a `package.json`, so a folder someone named `node_modules` to hold notes about npm +/// is never touched, and neither is a `.build` directory belonging to a build system that is not +/// SwiftPM. +struct DevJunkProjectScan: Sendable, Equatable { + var directoryName: String + var requiredSibling: String + /// How deep below a search root a project may sit. Four levels covers + /// ~/Developer/org/repo/package/node_modules without walking an entire home directory. + var maximumDepth: Int = 4 +} + +public enum DevJunkPaths { + /// Directory names the project walk refuses to descend into and refuses to accept as an + /// ancestor of a match. Package stores and VCS stores can contain anything, including copies + /// of the very directories being looked for. + static let prunedDirectoryNames: Set = [ + "node_modules", + ".build", + ".git", + "Pods", + "Carthage", + "DerivedData", + "Library", + ".Trash" + ] + + /// The only places a project-scanned category is allowed to look. Home itself is not on the + /// list: walking all of ~ would be slow and would sweep in ~/Library, and a project sitting + /// loose in the home directory is rare enough not to justify either cost. + public static func projectSearchRoots(home: String) -> [String] { + [ + "Developer", + "Projects", + "Documents", + "Desktop", + "Code", + "src", + "dev", + "git", + "repos", + "workspace", + "Sites" + ].map { normalize(joined(home, $0)) } + } + + /// Trees that are off limits regardless of what a category asks for. These are either the + /// user's data, the system's own files, or somewhere a delete would be actively harmful. + public static func deniedRoots(home: String) -> [String] { + [ + normalize(joined(home, "Library")), + normalize(joined(home, ".Trash")), + "/System", + "/Library", + "/usr", + "/bin", + "/sbin", + "/opt", + "/Applications" + ] + } + + public static func expand(_ path: String, home: String) -> String { + guard path.hasPrefix("~") else { + return path + } + + if path == "~" { + return home + } + + guard path.hasPrefix("~/") else { + // A "~otheruser" style path is not ours to expand, so leave it exactly as given and + // let the containment checks reject it. + return path + } + + return joined(home, String(path.dropFirst(2))) + } + + /// Collapses repeated and trailing separators so that "/a//b/" and "/a/b" compare equal. + /// Deliberately does not resolve symlinks or "..": the scanner never produces such paths, and + /// resolving them would mean touching the filesystem from what must stay a pure function. + public static func normalize(_ path: String) -> String { + let parts = components(path) + guard !parts.isEmpty else { + return path.hasPrefix("/") ? "/" : "" + } + + let prefix = path.hasPrefix("/") ? "/" : "" + return prefix + parts.joined(separator: "/") + } + + public static func components(_ path: String) -> [String] { + path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + } + + /// Component-wise containment. A plain string prefix test would say that + /// "~/Documents/MyApp-backup" lives inside "~/Documents/MyApp", which is exactly the class of + /// bug that would make this feature delete someone's source tree. + public static func isStrictDescendant(_ path: String, of ancestor: String) -> Bool { + let pathComponents = components(path) + let ancestorComponents = components(ancestor) + + guard pathComponents.count > ancestorComponents.count else { + return false + } + + return Array(pathComponents.prefix(ancestorComponents.count)) == ancestorComponents + } + + public static func isDescendantOrSelf(_ path: String, of ancestor: String) -> Bool { + components(path) == components(ancestor) || isStrictDescendant(path, of: ancestor) + } + + public static func isDirectChild(_ path: String, of parent: String) -> Bool { + components(path).count == components(parent).count + 1 && isStrictDescendant(path, of: parent) + } + + static func joined(_ base: String, _ component: String) -> String { + base.hasSuffix("/") ? base + component : base + "/" + component + } + + /// The label shown in the drill-down list. Fixed-root entries are identified by their own + /// name; a project entry is named after the project that owns it, because a list of twenty + /// rows all reading "node_modules" tells the user nothing. + public static func displayName(forPath path: String, category: DevJunkCategoryID) -> String { + let parts = components(path) + guard let last = parts.last else { + return path + } + + guard category.projectScan != nil, parts.count >= 2 else { + return last + } + + return parts[parts.count - 2] + "/" + last + } + + /// The tilde-abbreviated path shown as the row's tooltip, so the user can always see exactly + /// which directory a row would delete. + public static func abbreviate(_ path: String, home: String) -> String { + guard isDescendantOrSelf(normalize(path), of: normalize(home)) else { + return path + } + + let suffix = components(path).dropFirst(components(home).count) + return suffix.isEmpty ? "~" : "~/" + suffix.joined(separator: "/") + } +} + +// MARK: - Scan results + +public struct DevJunkEntry: Identifiable, Sendable, Equatable, Hashable { + public var category: DevJunkCategoryID + public var path: String + public var size: UInt64 + + public var id: String { path } + + public init(category: DevJunkCategoryID, path: String, size: UInt64) { + self.category = category + self.path = path + self.size = size + } + + public var displayName: String { + DevJunkPaths.displayName(forPath: path, category: category) + } +} + +public struct DevJunkCategoryReport: Identifiable, Sendable, Equatable { + public var category: DevJunkCategoryID + public var entries: [DevJunkEntry] + + public var id: DevJunkCategoryID { category } + + public init(category: DevJunkCategoryID, entries: [DevJunkEntry]) { + self.category = category + self.entries = entries + } + + public var totalSize: UInt64 { + entries.reduce(UInt64(0)) { $0 &+ $1.size } + } +} + +// MARK: - Selection + +public enum DevJunkSelectionState: Sendable, Equatable { + case none + case partial + case all +} + +/// Tracks which individual entries are checked. Selection is stored per entry path rather than per +/// category so that drilling into a category and unchecking one project survives the category-level +/// checkbox continuing to read "partial" — and so a rescan can drop entries that no longer exist +/// without silently carrying a stale path into a delete. +public struct DevJunkSelection: Sendable, Equatable { + private var selectedPaths: Set + + public init() { + selectedPaths = [] + } + + /// Seeds the selection from the categories' defaults. Every entry of a default-on category + /// starts checked; Archives, which can lose symbolication, starts unchecked. + public init(defaultsFor reports: [DevJunkCategoryReport]) { + selectedPaths = Set( + reports + .filter(\.category.isSelectedByDefault) + .flatMap(\.entries) + .map(\.path) + ) + } + + public func isSelected(_ entry: DevJunkEntry) -> Bool { + selectedPaths.contains(entry.path) + } + + public func state(for report: DevJunkCategoryReport) -> DevJunkSelectionState { + guard !report.entries.isEmpty else { + return .none + } + + let selected = report.entries.filter { selectedPaths.contains($0.path) }.count + if selected == 0 { + return .none + } + return selected == report.entries.count ? .all : .partial + } + + public mutating func toggle(_ entry: DevJunkEntry) { + if selectedPaths.contains(entry.path) { + selectedPaths.remove(entry.path) + } else { + selectedPaths.insert(entry.path) + } + } + + /// Toggling a category checkbox is "select all" unless everything is already selected, which + /// makes the half-checked state a single click away from full rather than from empty. + public mutating func toggle(_ report: DevJunkCategoryReport) { + setCategory(report, selected: state(for: report) != .all) + } + + public mutating func setCategory(_ report: DevJunkCategoryReport, selected: Bool) { + for entry in report.entries { + if selected { + selectedPaths.insert(entry.path) + } else { + selectedPaths.remove(entry.path) + } + } + } + + /// Drops paths that the latest scan no longer reports. Without this, a path deleted in a + /// previous pass (or one that the user removed in Finder) would stay checked forever and keep + /// inflating the "will free" total. + public mutating func reconcile(with reports: [DevJunkCategoryReport]) { + let live = Set(reports.flatMap(\.entries).map(\.path)) + selectedPaths.formIntersection(live) + } + + public func selectedEntries(in reports: [DevJunkCategoryReport]) -> [DevJunkEntry] { + reports.flatMap(\.entries).filter { selectedPaths.contains($0.path) } + } + + public func selectedBytes(in reports: [DevJunkCategoryReport]) -> UInt64 { + selectedEntries(in: reports).reduce(UInt64(0)) { $0 &+ $1.size } + } +} + +// MARK: - Removal + +public struct DevJunkRemovalFailure: Sendable, Equatable { + public var path: String + public var reason: String + + public init(path: String, reason: String) { + self.path = path + self.reason = reason + } +} + +public struct DevJunkRemovalResult: Sendable, Equatable { + public var trashedBytes: UInt64 + public var trashedCount: Int + public var failures: [DevJunkRemovalFailure] + + public init(trashedBytes: UInt64, trashedCount: Int, failures: [DevJunkRemovalFailure]) { + self.trashedBytes = trashedBytes + self.trashedCount = trashedCount + self.failures = failures + } +} + +public enum DevJunkRemovalEvent: Sendable { + case progress(trashedBytes: UInt64, currentPath: String) + case finished(DevJunkRemovalResult) +} + +public enum DevJunkRemover { + /// Re-runs every entry through its category's gate. The UI already only offers what the scanner + /// found, but the entries survive across rescans and across the confirmation alert, and a delete + /// is not the place to trust that nothing moved in between. + public static func validate( + _ entries: [DevJunkEntry], + home: String + ) -> (allowed: [DevJunkEntry], rejected: [DevJunkEntry]) { + var allowed: [DevJunkEntry] = [] + var rejected: [DevJunkEntry] = [] + + for entry in entries { + if entry.category.accepts(path: entry.path, home: home) { + allowed.append(entry) + } else { + rejected.append(entry) + } + } + + return (allowed, rejected) + } + + /// The text of the confirmation the user has to accept before anything moves. It names the + /// count and the total so a mis-click on "select all" is visible before it is irreversible. + public static func confirmationMessage(for entries: [DevJunkEntry]) -> String { + let bytes = entries.reduce(UInt64(0)) { $0 &+ $1.size } + let noun = entries.count == 1 ? "item" : "items" + return "Move \(entries.count) \(noun) (\(bytes.diskBytesString)) to the Trash?" + } + + /// Moves the validated entries to the Trash one at a time, streaming progress. The Trash is + /// used rather than an outright delete because these are the user's project folders: if the + /// heuristics ever get something wrong, the mistake has to be undoable. Space is therefore not + /// reclaimed until the Trash is emptied, which the UI says out loud. + public static func trashStream(entries: [DevJunkEntry], home: String) -> AsyncStream { + AsyncStream { continuation in + let task = Task.detached(priority: .userInitiated) { + let (allowed, rejected) = validate(entries, home: home) + var trashedBytes: UInt64 = 0 + var trashedCount = 0 + var failures = rejected.map { + DevJunkRemovalFailure(path: $0.path, reason: "No longer matches a developer-junk category") + } + + for entry in allowed { + if Task.isCancelled { + break + } + + let url = URL(fileURLWithPath: entry.path) + guard FileManager.default.fileExists(atPath: entry.path) else { + // Already gone (a previous pass, or the user's own cleanup). Not a failure + // worth reporting, but it must not count toward the reclaimed total. + continue + } + + do { + try FileManager.default.trashItem(at: url, resultingItemURL: nil) + trashedBytes &+= entry.size + trashedCount += 1 + continuation.yield(.progress(trashedBytes: trashedBytes, currentPath: entry.path)) + } catch { + failures.append( + DevJunkRemovalFailure(path: entry.path, reason: (error as NSError).localizedDescription) + ) + } + } + + continuation.yield( + .finished( + DevJunkRemovalResult(trashedBytes: trashedBytes, trashedCount: trashedCount, failures: failures) + ) + ) + continuation.finish() + } + + continuation.onTermination = { _ in task.cancel() } + } + } +} + +// MARK: - Scanning + +public enum DevJunkScanEvent: Sendable { + /// Fraction of the categories finished, plus the label of the one being worked on, so the UI + /// can show honest progress without knowing anything about the filesystem. + case progress(fraction: Double, label: String) + case category(DevJunkCategoryReport) + case finished([DevJunkCategoryReport]) +} + +/// A path the scanner has found but not yet vetted. `knownSize` is only meaningful for files, +/// whose size the bulk directory read already produced. +private struct DevJunkCandidate: Sendable { + var path: String + var isDirectory: Bool + var knownSize: UInt64 +} + +public enum DevJunkScanner { + /// Walks every category off the main actor, emitting each finished category as it lands so the + /// list fills in progressively instead of staying empty until the slowest tree (invariably + /// node_modules) is done. Cancellation is cooperative and checked between directories, so + /// closing the panel mid-scan stops the walk rather than leaving it churning. + public static func scanStream(home: String = NSHomeDirectory()) -> AsyncStream { + AsyncStream { continuation in + let task = Task.detached(priority: .utility) { + var reports: [DevJunkCategoryReport] = [] + let categories = DevJunkCategoryID.allCases + + for (index, category) in categories.enumerated() { + if Task.isCancelled { + break + } + + continuation.yield( + .progress(fraction: Double(index) / Double(categories.count), label: category.title) + ) + + let report = DevJunkCategoryReport( + category: category, + entries: entries(for: category, home: home) + ) + reports.append(report) + continuation.yield(.category(report)) + } + + if !Task.isCancelled { + continuation.yield(.progress(fraction: 1, label: "")) + continuation.yield(.finished(reports)) + } + continuation.finish() + } + + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Discovers and sizes one category. Every candidate goes through `category.accepts` even + /// though the discovery already constrained it — the gate is cheap and it means the acceptance + /// rules are enforced in exactly one place. + static func entries(for category: DevJunkCategoryID, home: String) -> [DevJunkEntry] { + var entries: [DevJunkEntry] = [] + + var candidates: [DevJunkCandidate] = [] + + if let scan = category.projectScan { + candidates += projectDirectories(scan: scan, home: home).map { + DevJunkCandidate(path: $0, isDirectory: true, knownSize: 0) + } + } + + for target in category.fixedTargets { + let root = DevJunkPaths.normalize(DevJunkPaths.expand(target.path, home: home)) + switch target.granularity { + case .wholeDirectory: + candidates.append(DevJunkCandidate(path: root, isDirectory: true, knownSize: 0)) + case .children: + candidates += childCandidates(of: root) + } + } + + for candidate in candidates where category.accepts(path: candidate.path, home: home) { + if Task.isCancelled { + return entries.sorted { $0.size > $1.size } + } + + // Loose files in a cache root already carry their size from the bulk read; only + // directories need the full recursive walk. + let size = candidate.isDirectory ? allocatedSize(at: candidate.path) : candidate.knownSize + if size > 0 { + entries.append(DevJunkEntry(category: category, path: candidate.path, size: size)) + } + } + + return entries.sorted { $0.size > $1.size } + } + + /// Immediate children of a cache root. Symlinks are skipped: Homebrew's cache in particular is + /// full of links pointing back into its own downloads folder, and following them would both + /// double-count the bytes and offer the user two rows for one file. + private static func childCandidates(of root: String) -> [DevJunkCandidate] { + guard let result = BulkDirectoryReader.read(at: root) else { + return [] + } + + return result.entries + .filter { !$0.isSymlink } + .map { + DevJunkCandidate( + path: DevJunkPaths.joined(root, $0.name), + isDirectory: $0.isDirectory, + knownSize: $0.allocatedSize + ) + } + .sorted { $0.path < $1.path } + } + + /// Breadth-first walk of the project roots, depth-capped and pruned. Breadth-first matters: + /// projects sit near the top of these roots, so the interesting matches are found long before + /// the walk exhausts the deep corners of ~/Documents. + private static func projectDirectories(scan: DevJunkProjectScan, home: String) -> [String] { + var found: [String] = [] + var seen: Set = [] + var frontier = DevJunkPaths.projectSearchRoots(home: home).filter { isDirectory($0) } + var depth = 0 + + while !frontier.isEmpty, depth <= scan.maximumDepth { + if Task.isCancelled { + return found + } + + var next: [String] = [] + + for directory in frontier { + if Task.isCancelled { + return found + } + + guard let result = BulkDirectoryReader.read(at: directory) else { + continue + } + + let names = Set(result.entries.map(\.name)) + for entry in result.entries where entry.isDirectory && !entry.isSymlink { + let childPath = DevJunkPaths.joined(directory, entry.name) + + if entry.name == scan.directoryName { + // The sibling manifest is the whole safety argument: a directory only + // counts when the folder holding it is demonstrably a project of the right + // kind, not merely a folder that happens to use the same name. + // Search roots can overlap (a symlinked ~/Developer, a project reachable + // from two of them), so the same directory must not be listed twice. + if names.contains(scan.requiredSibling), seen.insert(childPath).inserted { + found.append(childPath) + } + continue + } + + if DevJunkPaths.prunedDirectoryNames.contains(entry.name) || entry.name.hasPrefix(".") { + continue + } + + next.append(childPath) + } + } + + frontier = next + depth += 1 + } + + return found + } + + private static func isDirectory(_ path: String) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + /// Sums allocated size for a whole subtree using the bulk directory reader, checking for + /// cancellation between directories. A node_modules tree can hold six figures of tiny files, so + /// this is the part that must never run on the main actor and must be able to stop early. + static func allocatedSize(at path: String) -> UInt64 { + var total: UInt64 = 0 + var stack = [path] + + while let current = stack.popLast() { + if Task.isCancelled { + return total + } + + guard let result = BulkDirectoryReader.read(at: current) else { + continue + } + + for entry in result.entries where !entry.isSymlink { + if entry.isDirectory { + stack.append(DevJunkPaths.joined(current, entry.name)) + } else { + total &+= entry.allocatedSize + } + } + } + + return total + } +} diff --git a/Sources/DMonteCore/CleanDriveDevJunkView.swift b/Sources/DMonteCore/CleanDriveDevJunkView.swift new file mode 100644 index 0000000..68f1d2f --- /dev/null +++ b/Sources/DMonteCore/CleanDriveDevJunkView.swift @@ -0,0 +1,421 @@ +import AppKit +import SwiftUI + +/// The developer-junk sheet: eleven categories, each expandable into the individual directories +/// that make up its total so the user can keep the one project they still care about. It is a +/// separate surface from the main Clean Drive list because the interaction is different — the +/// system categories are a checkbox each, these need drilling into. +struct CleanDriveDevJunkPanel: View { + var onClose: () -> Void + + @State private var reports: [DevJunkCategoryReport] = [] + @State private var selection = DevJunkSelection() + @State private var expanded: Set = [] + @State private var isScanning = true + @State private var scanFraction: Double = 0 + @State private var scanLabel = "" + @State private var statusMessage = "Scanning..." + @State private var isTrashing = false + @State private var didStopScan = false + @State private var scanTask: Task? + + private let home = NSHomeDirectory() + private let layout = DevJunkLayout.current + + var body: some View { + VStack(spacing: 0) { + header + summary + categoryList + footer + } + .frame(width: layout.sheetSize.width, height: layout.sheetSize.height) + .task { + startScan() + } + .onDisappear { + // Closing the sheet mid-scan must stop the walk; a node_modules sweep left running in + // the background would keep spinning a core for a panel nobody is looking at. + scanTask?.cancel() + } + } + + private var header: some View { + HStack { + Text("Developer Junk") + .font(.system(size: layout.titleFontSize, weight: .bold)) + + Spacer() + + Button { + scanTask?.cancel() + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: layout.closeIconSize, weight: .bold)) + .frame(width: layout.closeButtonSize, height: layout.closeButtonSize) + } + .buttonStyle(.plain) + .disabled(isTrashing) + .help("Close") + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.top, layout.verticalPadding) + .padding(.bottom, layout.headerBottomPadding) + } + + private var summary: some View { + VStack(alignment: .leading, spacing: layout.summarySpacing) { + HStack(alignment: .firstTextBaseline) { + Text(selectedBytes.diskBytesString) + .font(.system(size: layout.summaryValueFontSize, weight: .semibold, design: .rounded)) + .monospacedDigit() + + Text("selected of \(totalBytes.diskBytesString) found") + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Spacer() + } + + if isScanning { + HStack(spacing: layout.rowSpacing) { + ProgressView(value: scanFraction) + .progressViewStyle(.linear) + + Button("Stop") { + stopScan() + } + .buttonStyle(.plain) + .font(.system(size: layout.captionFontSize, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + + Text(isScanning && !scanLabel.isEmpty ? "Scanning \(scanLabel)..." : statusMessage) + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.bottom, layout.summaryBottomPadding) + } + + private var categoryList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: layout.rowSpacing) { + ForEach(reports) { report in + categoryRow(report) + + if expanded.contains(report.category) { + detailBlock(report) + } + } + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.vertical, layout.listVerticalPadding) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func categoryRow(_ report: DevJunkCategoryReport) -> some View { + HStack(spacing: layout.rowSpacing) { + Button { + selection.toggle(report) + } label: { + DevJunkCheckbox(state: selection.state(for: report), size: layout.checkboxSize) + } + .buttonStyle(.plain) + .disabled(report.entries.isEmpty || isTrashing) + .help(report.entries.isEmpty ? "Nothing found" : "Select every entry in this category") + + Button { + toggleExpansion(report.category) + } label: { + HStack(spacing: layout.rowSpacing) { + Image(systemName: expanded.contains(report.category) ? "chevron.down" : "chevron.right") + .font(.system(size: layout.chevronFontSize, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: layout.chevronWidth, alignment: .leading) + + Text(report.category.title) + .font(.system(size: layout.rowTitleFontSize, weight: .semibold)) + .lineLimit(1) + + Spacer(minLength: layout.rowSpacing) + + Text(report.entries.isEmpty ? "—" : report.totalSize.diskBytesString) + .font(.system(size: layout.rowValueFontSize, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(report.entries.isEmpty) + } + .opacity(report.entries.isEmpty ? 0.45 : 1) + } + + private func detailBlock(_ report: DevJunkCategoryReport) -> some View { + VStack(alignment: .leading, spacing: layout.rowSpacing) { + Text(report.category.detail) + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + ForEach(report.entries) { entry in + Button { + selection.toggle(entry) + } label: { + HStack(spacing: layout.rowSpacing) { + DevJunkCheckbox( + state: selection.isSelected(entry) ? .all : .none, + size: layout.checkboxSize + ) + + Text(entry.displayName) + .font(.system(size: layout.entryFontSize, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: layout.rowSpacing) + + Text(entry.size.diskBytesString) + .font(.system(size: layout.entryFontSize, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isTrashing) + .help(DevJunkPaths.abbreviate(entry.path, home: home)) + } + } + .padding(.leading, layout.detailIndent) + .padding(.bottom, layout.detailBottomPadding) + } + + private var footer: some View { + VStack(spacing: layout.summarySpacing) { + Text("Selected items are moved to the Trash. The space is reclaimed once you empty it.") + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button { + confirmAndTrash() + } label: { + Text(isTrashing ? "Moving to Trash..." : "Move to Trash") + .font(.system(size: layout.buttonFontSize, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, layout.buttonHorizontalPadding) + .frame(height: layout.buttonHeight) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: layout.buttonCornerRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: layout.buttonCornerRadius, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(selectedBytes == 0 || isTrashing) + .opacity(selectedBytes == 0 || isTrashing ? 0.45 : 1) + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.bottom, layout.verticalPadding) + .padding(.top, layout.footerTopPadding) + } + + private var selectedBytes: UInt64 { + selection.selectedBytes(in: reports) + } + + private var totalBytes: UInt64 { + reports.reduce(UInt64(0)) { $0 &+ $1.totalSize } + } + + private func toggleExpansion(_ category: DevJunkCategoryID) { + if expanded.contains(category) { + expanded.remove(category) + } else { + expanded.insert(category) + } + } + + private func startScan() { + scanTask?.cancel() + reports = [] + isScanning = true + didStopScan = false + scanFraction = 0 + scanLabel = "" + statusMessage = "Scanning..." + + scanTask = Task { + for await event in DevJunkScanner.scanStream(home: home) { + switch event { + case let .progress(fraction, label): + scanFraction = fraction + scanLabel = label + case let .category(report): + // Categories land one at a time so the list fills in as it goes; seeding the + // selection per category keeps the "will free" total honest during the scan. + reports.append(report) + if report.category.isSelectedByDefault { + selection.setCategory(report, selected: true) + } + case let .finished(finished): + reports = finished + selection.reconcile(with: finished) + } + } + + isScanning = false + scanLabel = "" + // Cancelling makes the stream finish exactly as a completed scan does, so the stopped + // case has to be remembered explicitly — otherwise a partial total would be announced + // as if it were the whole picture. + statusMessage = didStopScan ? "Scan stopped — showing what was found so far" : summaryMessage() + } + } + + private func stopScan() { + didStopScan = true + scanTask?.cancel() + } + + private func summaryMessage() -> String { + let found = reports.reduce(0) { $0 + $1.entries.count } + guard found > 0 else { + return "No developer junk found" + } + return "\(found) item\(found == 1 ? "" : "s") found" + } + + private func confirmAndTrash() { + let entries = selection.selectedEntries(in: reports) + guard !entries.isEmpty else { + return + } + + let alert = NSAlert() + alert.messageText = DevJunkRemover.confirmationMessage(for: entries) + alert.informativeText = "You can put them back from the Trash if you change your mind. " + + "Anything still in use by a running build may fail to move." + alert.alertStyle = .warning + alert.addButton(withTitle: "Move to Trash") + alert.addButton(withTitle: "Cancel") + + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + + isTrashing = true + statusMessage = "Moving to Trash..." + + Task { + var result = DevJunkRemovalResult(trashedBytes: 0, trashedCount: 0, failures: []) + + for await event in DevJunkRemover.trashStream(entries: entries, home: home) { + switch event { + case let .progress(_, currentPath): + statusMessage = "Trashing \(DevJunkPaths.abbreviate(currentPath, home: home))" + case let .finished(finished): + result = finished + } + } + + isTrashing = false + startScan() + presentResult(result) + } + } + + private func presentResult(_ result: DevJunkRemovalResult) { + guard !result.failures.isEmpty else { + return + } + + let details = result.failures.prefix(6).map { + "• \(DevJunkPaths.abbreviate($0.path, home: home)) — \($0.reason)" + }.joined(separator: "\n") + let more = result.failures.count > 6 ? "\n…and \(result.failures.count - 6) more" : "" + + let alert = NSAlert() + alert.messageText = "Moved \(result.trashedCount) item\(result.trashedCount == 1 ? "" : "s") " + + "(\(result.trashedBytes.diskBytesString)) to the Trash" + alert.informativeText = "These were left in place:\n\n\(details)\(more)" + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +/// Tri-state checkbox. The half-filled state is what makes drill-down legible: a category the user +/// has partly deselected must not look identical to one they left alone. +private struct DevJunkCheckbox: View { + var state: DevJunkSelectionState + var size: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(state == .none ? Color.secondary.opacity(0.28) : Color.accentColor) + .frame(width: size, height: size) + .overlay { + switch state { + case .none: + EmptyView() + case .partial: + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(Color.white) + .frame(width: size * 0.52, height: max(1.5, size * 0.14)) + case .all: + Image(systemName: "checkmark") + .font(.system(size: size * 0.62, weight: .black)) + .foregroundStyle(.white) + } + } + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(Color.white.opacity(0.22), lineWidth: 0.6) + } + } +} + +private struct DevJunkLayout { + let scale: CGFloat + + static var current: DevJunkLayout { + DevJunkLayout(scale: CleanDriveSizing.currentScale) + } + + var sheetSize: NSSize { CleanDriveSizing.devJunkSize() } + var horizontalPadding: CGFloat { 16 * scale } + var verticalPadding: CGFloat { 14 * scale } + var headerBottomPadding: CGFloat { 8 * scale } + var summarySpacing: CGFloat { 6 * scale } + var summaryBottomPadding: CGFloat { 8 * scale } + var summaryValueFontSize: CGFloat { 20 * scale } + var titleFontSize: CGFloat { 16 * scale } + var closeIconSize: CGFloat { 11 * scale } + var closeButtonSize: CGFloat { 24 * scale } + var captionFontSize: CGFloat { 11 * scale } + var rowSpacing: CGFloat { 8 * scale } + var listVerticalPadding: CGFloat { 4 * scale } + var checkboxSize: CGFloat { 15 * scale } + var chevronFontSize: CGFloat { 10 * scale } + var chevronWidth: CGFloat { 12 * scale } + var rowTitleFontSize: CGFloat { 13 * scale } + var rowValueFontSize: CGFloat { 12 * scale } + var entryFontSize: CGFloat { 11 * scale } + var detailIndent: CGFloat { 23 * scale } + var detailBottomPadding: CGFloat { 6 * scale } + var footerTopPadding: CGFloat { 8 * scale } + var buttonFontSize: CGFloat { 13 * scale } + var buttonHorizontalPadding: CGFloat { 18 * scale } + var buttonHeight: CGFloat { 28 * scale } + var buttonCornerRadius: CGFloat { 7 * scale } +} diff --git a/Sources/DMonteCore/CleanDriveSizing.swift b/Sources/DMonteCore/CleanDriveSizing.swift index 8f2aace..7244787 100644 --- a/Sources/DMonteCore/CleanDriveSizing.swift +++ b/Sources/DMonteCore/CleanDriveSizing.swift @@ -6,6 +6,18 @@ public enum CleanDriveSizing { return NSSize(width: (420 * scale).rounded(), height: (500 * scale).rounded()) } + /// The developer-junk sheet, which is centred *over* the panel by `PreferencesOverlay` and so + /// must never exceed it. Unlike the settings sheet this one wants every point it can get: it + /// lists eleven categories that each expand into a drill-down, so it is sized to fill the panel + /// right up to the overlay's own padding rather than to a comfortable fixed size. + public static func devJunkSize() -> NSSize { + let panel = preferredSize() + // PreferencesOverlay adds 18pt of padding on every side; a sheet sized to the full panel + // becomes panel+36 once padded and spills out of it. + let overlayChrome: CGFloat = 36 + return NSSize(width: panel.width - overlayChrome, height: panel.height - overlayChrome) + } + static var currentScale: CGFloat { let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) let screenScale = visibleFrame.height / 950 diff --git a/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift b/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift new file mode 100644 index 0000000..dd553ba --- /dev/null +++ b/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift @@ -0,0 +1,517 @@ +import XCTest +@testable import DMonteCore + +final class CleanDriveDevJunkTests: XCTestCase { + private let home = "/Users/tester" + + // MARK: - Path primitives + + func testStrictDescendantIsComponentWiseNotStringPrefix() { + // The whole feature turns on this: a plain hasPrefix test would say the backup folder + // lives inside the project, and that is how a cleaner ends up deleting someone's source. + XCTAssertFalse(DevJunkPaths.isStrictDescendant("/Users/tester/Code/MyApp-backup", of: "/Users/tester/Code/MyApp")) + XCTAssertTrue(DevJunkPaths.isStrictDescendant("/Users/tester/Code/MyApp/src", of: "/Users/tester/Code/MyApp")) + XCTAssertFalse(DevJunkPaths.isStrictDescendant("/Users/tester/Code/MyApp", of: "/Users/tester/Code/MyApp")) + } + + func testDescendantOrSelfAcceptsTheRootItself() { + XCTAssertTrue(DevJunkPaths.isDescendantOrSelf("/Users/tester/Code", of: "/Users/tester/Code")) + XCTAssertTrue(DevJunkPaths.isDescendantOrSelf("/Users/tester/Code/App", of: "/Users/tester/Code")) + XCTAssertFalse(DevJunkPaths.isDescendantOrSelf("/Users/tester", of: "/Users/tester/Code")) + } + + func testDirectChildIsExactlyOneLevelDown() { + XCTAssertTrue(DevJunkPaths.isDirectChild("/a/b/c", of: "/a/b")) + XCTAssertFalse(DevJunkPaths.isDirectChild("/a/b/c/d", of: "/a/b")) + XCTAssertFalse(DevJunkPaths.isDirectChild("/a/b", of: "/a/b")) + } + + func testNormalizeCollapsesRedundantSeparators() { + XCTAssertEqual(DevJunkPaths.normalize("/a//b/"), "/a/b") + XCTAssertEqual(DevJunkPaths.normalize("/"), "/") + } + + func testExpandOnlyHandlesTheCurrentUsersTilde() { + XCTAssertEqual(DevJunkPaths.expand("~/Library", home: home), "/Users/tester/Library") + XCTAssertEqual(DevJunkPaths.expand("~", home: home), home) + XCTAssertEqual(DevJunkPaths.expand("/absolute", home: home), "/absolute") + // "~someone" belongs to another account and must be left alone rather than mangled. + XCTAssertEqual(DevJunkPaths.expand("~other/Library", home: home), "~other/Library") + } + + func testAbbreviateOnlyShortensPathsInsideHome() { + XCTAssertEqual(DevJunkPaths.abbreviate("/Users/tester/Code/App", home: home), "~/Code/App") + XCTAssertEqual(DevJunkPaths.abbreviate("/Users/other/Code", home: home), "/Users/other/Code") + } + + func testDisplayNameNamesProjectEntriesAfterTheirProject() { + // A drill-down list of twenty rows all reading "node_modules" tells the user nothing. + XCTAssertEqual( + DevJunkPaths.displayName(forPath: "/Users/tester/Code/MyApp/node_modules", category: .nodeModules), + "MyApp/node_modules" + ) + XCTAssertEqual( + DevJunkPaths.displayName( + forPath: "/Users/tester/Library/Developer/Xcode/DerivedData/App-abc", + category: .xcodeDerivedData + ), + "App-abc" + ) + } + + // MARK: - Category acceptance: the positive cases + + func testEachFixedCategoryAcceptsAKnownEntryUnderItsOwnRoot() { + let cases: [(DevJunkCategoryID, String)] = [ + (.xcodeDerivedData, "~/Library/Developer/Xcode/DerivedData/MyApp-abcdefgh"), + (.xcodeArchives, "~/Library/Developer/Xcode/Archives/2026-07-21"), + (.deviceSupport, "~/Library/Developer/Xcode/iOS DeviceSupport/17.0 (21A342)"), + (.simulatorCaches, "~/Library/Developer/CoreSimulator/Caches/dyld"), + (.cocoaPodsCache, "~/Library/Caches/CocoaPods/Pods"), + (.jvmCaches, "~/.gradle/caches/modules-2"), + (.jvmCaches, "~/.m2/repository/org"), + (.scriptingCaches, "~/.npm/_cacache"), + (.scriptingCaches, "~/Library/Caches/pip/wheels"), + (.homebrewCache, "~/Library/Caches/Homebrew/downloads"), + (.dockerLeftovers, "~/Library/Containers/com.docker.docker/Data/log/host") + ] + + for (category, path) in cases { + XCTAssertTrue( + category.accepts(path: path, home: home), + "\(category.rawValue) should accept its own entry \(path)" + ) + } + } + + func testProjectCategoriesAcceptDirectoriesInsideTheProjectRoots() { + XCTAssertTrue(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/MyApp/node_modules", home: home)) + XCTAssertTrue(DevJunkCategoryID.nodeModules.accepts(path: "~/Documents/work/site/node_modules", home: home)) + XCTAssertTrue(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Code/MyLib/.build", home: home)) + } + + // MARK: - Category acceptance: nothing outside its intended root + + /// Paths that are either the user's own work or something a toolchain cannot regenerate. If any + /// category ever claims one of these, the feature deletes something it must not. + private var sacredPaths: [String] { + [ + // Source trees that sit right next to the junk. + "~/Developer/MyApp", + "~/Developer/MyApp/src", + "~/Documents/Taxes", + "~/Documents/MyApp/Sources/main.swift", + "~/Desktop/screenshot.png", + "~/Code/MyLib/Package.swift", + // Look-alike names a substring match would happily eat. + "~/Developer/node_modules_backup", + "~/Developer/MyApp/node_modules.zip", + "~/Developer/MyApp/.buildkite", + // Xcode state that is not a cache. + "~/Library/Developer/Xcode/UserData/FontAndColorThemes/Mine.xccolortheme", + "~/Library/Developer/Xcode/Templates", + "~/Library/Developer/Xcode/DerivedData", + // Simulator devices hold installed apps and their data, not caches. + "~/Library/Developer/CoreSimulator/Devices/8F0C-DEAD-BEEF", + "~/Library/Developer/CoreSimulator/Devices", + // Gradle distributions are pinned by checksum; Maven settings are configuration. + "~/.gradle/wrapper/dists/gradle-8.5-bin", + "~/.gradle/gradle.properties", + "~/.m2/settings.xml", + // npm/pip configuration and logs, not download caches. + "~/.npmrc", + "~/.npm/_logs", + // A private CocoaPods spec repo is a git checkout that may not be re-clonable. + "~/.cocoapods/repos/private-specs", + // Installed Homebrew software, as opposed to the downloads that produced it. + "/opt/homebrew/Cellar/git/2.44.0", + "/usr/local/Cellar/git/2.44.0", + // The Docker disk image holds every image, container and volume the user owns. + "~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw", + "~/Library/Containers/com.docker.docker/Data", + // Unrelated user data and system files. + "~/Library/Mail", + "~/Library/Application Support/MobileSync/Backup", + "~/.ssh/id_ed25519", + "~/.Trash/MyApp/node_modules", + "/System/Library/CoreServices", + "/Applications/Xcode.app", + "/", + "" + ] + } + + func testNoCategoryAcceptsAnythingOutsideItsIntendedRoot() { + for category in DevJunkCategoryID.allCases { + for path in sacredPaths { + XCTAssertFalse( + category.accepts(path: path, home: home), + "\(category.rawValue) must not accept \(path)" + ) + } + } + } + + func testCategoriesDoNotPoachEachOthersEntries() { + // Every category's own entry must be rejected by all the others, so a mis-tagged entry + // cannot survive the pre-delete revalidation. + let owned: [(DevJunkCategoryID, String)] = [ + (.xcodeDerivedData, "~/Library/Developer/Xcode/DerivedData/MyApp-abcdefgh"), + (.xcodeArchives, "~/Library/Developer/Xcode/Archives/2026-07-21"), + (.deviceSupport, "~/Library/Developer/Xcode/iOS DeviceSupport/17.0 (21A342)"), + (.simulatorCaches, "~/Library/Developer/CoreSimulator/Caches/dyld"), + (.nodeModules, "~/Developer/MyApp/node_modules"), + (.swiftPackageBuilds, "~/Developer/MyLib/.build"), + (.cocoaPodsCache, "~/Library/Caches/CocoaPods/Pods"), + (.jvmCaches, "~/.gradle/caches/modules-2"), + (.scriptingCaches, "~/.npm/_cacache"), + (.homebrewCache, "~/Library/Caches/Homebrew/downloads"), + (.dockerLeftovers, "~/Library/Containers/com.docker.docker/Data/log/host") + ] + + for (owner, path) in owned { + for category in DevJunkCategoryID.allCases where category != owner { + XCTAssertFalse( + category.accepts(path: path, home: home), + "\(category.rawValue) must not claim \(owner.rawValue)'s entry \(path)" + ) + } + } + } + + func testFixedCategoriesOnlyAcceptDirectChildrenNotDeeperPaths() { + // Handing the remover a nested path would let it delete a fragment of a cache and leave + // the toolchain with a half-populated directory. + XCTAssertFalse( + DevJunkCategoryID.xcodeDerivedData.accepts( + path: "~/Library/Developer/Xcode/DerivedData/MyApp-abc/Build/Products", + home: home + ) + ) + XCTAssertFalse( + DevJunkCategoryID.homebrewCache.accepts(path: "~/Library/Caches/Homebrew/downloads/abc.tar.gz", home: home) + ) + } + + func testProjectCategoriesRejectMatchesOutsideTheSearchRoots() { + // ~/Library is not a project root, and neither is an arbitrary volume. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/Library/Caches/thing/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "/Volumes/Backup/MyApp/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "/tmp/.build", home: home)) + } + + func testProjectCategoriesRejectNestedAndMisnamedDirectories() { + // A node_modules inside another one is already covered by its ancestor. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/app/node_modules/dep/node_modules", home: home) + ) + // ...and anything reached through a VCS or package store is off limits entirely. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/app/.git/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Developer/app/Pods/.build", home: home)) + // The last component must match exactly, not merely start or end with the name. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/app/node_modules2", home: home)) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Developer/app/build", home: home)) + } + + func testPruneNamesOnlyVetoComponentsBelowTheSearchRoot() { + // A component of the *home directory* is not part of the user's project structure, so a + // home that happens to sit under a folder named like a package store must not make every + // project inside it unmatchable. Home directories on external volumes hit this. + let awkwardHome = "/Volumes/Pods/tester" + XCTAssertTrue( + DevJunkCategoryID.nodeModules.accepts(path: "/Volumes/Pods/tester/Developer/App/node_modules", home: awkwardHome) + ) + + // Below the root the veto still applies. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "/Volumes/Pods/tester/Developer/Pods/App/node_modules", home: awkwardHome) + ) + } + + func testEveryCategoryStaysInsideTheUsersHomeDirectory() { + for category in DevJunkCategoryID.allCases { + for root in category.allowedRoots(home: home) { + XCTAssertTrue( + DevJunkPaths.isStrictDescendant(root, of: home), + "\(category.rawValue) declares a root outside home: \(root)" + ) + } + } + } + + func testArchivesAreTheOnlyCategoryOffByDefault() { + // Archives carry the dSYMs that make old crash reports readable, which is the one thing + // here that does not come back. + for category in DevJunkCategoryID.allCases { + XCTAssertEqual( + category.isSelectedByDefault, + category != .xcodeArchives, + "\(category.rawValue) default selection changed" + ) + } + } + + // MARK: - Selection + + private func report(_ category: DevJunkCategoryID, _ paths: [(String, UInt64)]) -> DevJunkCategoryReport { + DevJunkCategoryReport( + category: category, + entries: paths.map { DevJunkEntry(category: category, path: $0.0, size: $0.1) } + ) + } + + private var sampleReports: [DevJunkCategoryReport] { + [ + report(.xcodeDerivedData, [ + ("/Users/tester/Library/Developer/Xcode/DerivedData/A-1", 100), + ("/Users/tester/Library/Developer/Xcode/DerivedData/B-2", 200) + ]), + report(.xcodeArchives, [ + ("/Users/tester/Library/Developer/Xcode/Archives/2026-07-21", 500) + ]) + ] + } + + func testDefaultSelectionSkipsArchives() { + let reports = sampleReports + let selection = DevJunkSelection(defaultsFor: reports) + + XCTAssertEqual(selection.state(for: reports[0]), .all) + XCTAssertEqual(selection.state(for: reports[1]), .none) + XCTAssertEqual(selection.selectedBytes(in: reports), 300) + } + + func testDeselectingOneEntryLeavesTheCategoryPartial() { + let reports = sampleReports + var selection = DevJunkSelection(defaultsFor: reports) + + selection.toggle(reports[0].entries[0]) + + XCTAssertEqual(selection.state(for: reports[0]), .partial) + XCTAssertEqual(selection.selectedBytes(in: reports), 200) + } + + func testTogglingAPartialCategorySelectsAllOfIt() { + // A half-checked box should be one click from full, not one click from empty. + let reports = sampleReports + var selection = DevJunkSelection(defaultsFor: reports) + selection.toggle(reports[0].entries[0]) + + selection.toggle(reports[0]) + + XCTAssertEqual(selection.state(for: reports[0]), .all) + + selection.toggle(reports[0]) + XCTAssertEqual(selection.state(for: reports[0]), .none) + } + + func testEmptyCategoryReadsAsUnselected() { + let empty = report(.nodeModules, []) + let selection = DevJunkSelection(defaultsFor: [empty]) + + XCTAssertEqual(selection.state(for: empty), .none) + } + + func testReconcileDropsPathsTheLatestScanNoLongerReports() { + let reports = sampleReports + var selection = DevJunkSelection(defaultsFor: reports) + + let shrunk = [report(.xcodeDerivedData, [("/Users/tester/Library/Developer/Xcode/DerivedData/A-1", 100)])] + selection.reconcile(with: shrunk) + + XCTAssertEqual(selection.selectedBytes(in: shrunk), 100) + // The vanished path must not come back if the same report is offered again. + XCTAssertEqual(selection.selectedBytes(in: reports), 100) + } + + // MARK: - Removal safety + + func testValidateRejectsAnEntryPointingOutsideItsCategory() { + // Entries survive across rescans and across the confirmation alert, so the remover + // re-checks every one rather than trusting that nothing moved in between. + let forged = DevJunkEntry(category: .nodeModules, path: "/Users/tester/Documents/Taxes", size: 999) + let genuine = DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/App/node_modules", size: 10) + + let (allowed, rejected) = DevJunkRemover.validate([forged, genuine], home: home) + + XCTAssertEqual(allowed, [genuine]) + XCTAssertEqual(rejected, [forged]) + } + + func testConfirmationMessageNamesTheCountAndTotal() { + let entries = [ + DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/A/node_modules", size: 1_000_000), + DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/B/node_modules", size: 2_000_000) + ] + + let message = DevJunkRemover.confirmationMessage(for: entries) + + XCTAssertTrue(message.contains("2 items"), message) + XCTAssertTrue(message.contains(UInt64(3_000_000).diskBytesString), message) + } + + func testTrashStreamLeavesARejectedPathOnDisk() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + + let sourceDirectory = DevJunkPaths.joined(sandbox, "Documents/Taxes") + try FileManager.default.createDirectory(atPath: sourceDirectory, withIntermediateDirectories: true) + let forged = DevJunkEntry(category: .nodeModules, path: sourceDirectory, size: 1) + + var result = DevJunkRemovalResult(trashedBytes: 0, trashedCount: 0, failures: []) + for await event in DevJunkRemover.trashStream(entries: [forged], home: sandbox) { + if case let .finished(finished) = event { + result = finished + } + } + + XCTAssertEqual(result.trashedCount, 0) + XCTAssertEqual(result.failures.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: sourceDirectory)) + } + + // MARK: - Scanning a real directory tree + + private func makeSandbox() throws -> String { + let root = DevJunkPaths.joined(NSTemporaryDirectory(), "DevJunkTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + return DevJunkPaths.normalize(root) + } + + private func writeFile(_ path: String, bytes: Int = 4_096) throws { + let directory = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let data = Data(repeating: 0x41, count: bytes) + try data.write(to: URL(fileURLWithPath: path)) + } + + /// Builds a home directory holding one of everything: a real npm project, a real SwiftPM + /// package, a real DerivedData entry, and three decoys that a sloppier matcher would eat. + private func populateSandbox(_ home: String) throws { + try writeFile(DevJunkPaths.joined(home, "Developer/MyApp/package.json")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyApp/node_modules/left-pad/index.js")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyApp/src/main.js")) + + try writeFile(DevJunkPaths.joined(home, "Developer/MyLib/Package.swift")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyLib/.build/debug/MyLib.o")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyLib/Sources/MyLib/MyLib.swift")) + + // Decoy 1: a folder named node_modules with no package.json beside it. + try writeFile(DevJunkPaths.joined(home, "Developer/Notes/node_modules/reading-list.md")) + // Decoy 2: a look-alike name. + try writeFile(DevJunkPaths.joined(home, "Developer/node_modules_backup/archive.tar")) + // Decoy 3: a .build belonging to something that is not a SwiftPM package. + try writeFile(DevJunkPaths.joined(home, "Developer/Website/.build/output.css")) + + try writeFile(DevJunkPaths.joined(home, "Library/Developer/Xcode/DerivedData/MyApp-abc/Build/MyApp.o")) + try writeFile(DevJunkPaths.joined(home, "Library/Developer/Xcode/UserData/FontAndColorThemes/Mine.xccolortheme")) + } + + private func scan(home: String) async -> [DevJunkCategoryReport] { + var reports: [DevJunkCategoryReport] = [] + for await event in DevJunkScanner.scanStream(home: home) { + if case let .finished(finished) = event { + reports = finished + } + } + return reports + } + + func testScanFindsRealProjectJunkAndIgnoresTheDecoys() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + let reports = await scan(home: sandbox) + let paths = Set(reports.flatMap(\.entries).map(\.path)) + + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Developer/MyApp/node_modules"))) + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Developer/MyLib/.build"))) + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Library/Developer/Xcode/DerivedData/MyApp-abc"))) + + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, "Developer/Notes/node_modules"))) + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, "Developer/node_modules_backup"))) + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, "Developer/Website/.build"))) + } + + func testScanNeverListsAPathOutsideSomeCategoryRoot() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + let reports = await scan(home: sandbox) + + for report in reports { + for entry in report.entries { + XCTAssertEqual(entry.category, report.category) + XCTAssertTrue( + entry.category.accepts(path: entry.path, home: sandbox), + "scan produced \(entry.path) which its own category rejects" + ) + XCTAssertTrue( + entry.category.allowedRoots(home: sandbox).contains { + DevJunkPaths.isDescendantOrSelf(entry.path, of: $0) + }, + "scan produced \(entry.path) outside \(entry.category.rawValue)'s roots" + ) + } + } + + // The source trees and Xcode's user settings must not appear anywhere in the results. + let paths = Set(reports.flatMap(\.entries).map(\.path)) + for survivor in [ + "Developer/MyApp/src", + "Developer/MyLib/Sources", + "Library/Developer/Xcode/UserData" + ] { + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, survivor)), survivor) + } + } + + func testScanReportsEveryCategoryAndSizesWhatItFinds() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + let reports = await scan(home: sandbox) + + XCTAssertEqual(reports.map(\.category), DevJunkCategoryID.allCases) + + let nodeModules = try XCTUnwrap(reports.first { $0.category == .nodeModules }) + XCTAssertEqual(nodeModules.entries.count, 1) + XCTAssertGreaterThan(nodeModules.totalSize, 0) + XCTAssertEqual(nodeModules.entries[0].displayName, "MyApp/node_modules") + } + + func testScanStreamStopsWhenTheConsumerWalksAway() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + // Breaking out of the loop terminates the stream, which must cancel the producing task + // rather than leaving it walking node_modules for a panel that is already closed. + var seen = 0 + for await _ in DevJunkScanner.scanStream(home: sandbox) { + seen += 1 + break + } + + XCTAssertEqual(seen, 1) + } + + func testEntriesForCategoryAreOrderedLargestFirst() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + + let derivedData = DevJunkPaths.joined(sandbox, "Library/Developer/Xcode/DerivedData") + try writeFile(DevJunkPaths.joined(derivedData, "Small-1/a.o"), bytes: 1_024) + try writeFile(DevJunkPaths.joined(derivedData, "Large-2/a.o"), bytes: 512 * 1_024) + + let entries = DevJunkScanner.entries(for: .xcodeDerivedData, home: sandbox) + + XCTAssertEqual(entries.count, 2) + XCTAssertEqual(entries[0].displayName, "Large-2") + XCTAssertGreaterThan(entries[0].size, entries[1].size) + } +} diff --git a/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift b/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift index dfa1e24..86a2769 100644 --- a/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift +++ b/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift @@ -23,10 +23,29 @@ final class SettingsOverlaySizingTests: XCTestCase { assertSheetFits(panel: FocusTimerSizing.preferredSize(), sheet: FocusTimerSizing.settingsSize(), tool: "Focus Timer") } + /// Clean Drive's developer-junk sheet is sized to fill the panel rather than to a comfortable + /// literal, so it is the one sheet that would spill the instant the 18pt-per-side padding + /// `PreferencesOverlay` adds were forgotten. Asserted against the padded size for that reason. + func testCleanDriveDevJunkSheetFitsItsPanelOncePadded() { + let panel = CleanDriveSizing.preferredSize() + let sheet = CleanDriveSizing.devJunkSize() + let overlayChrome: CGFloat = 36 + + assertSheetFits(panel: panel, sheet: sheet, tool: "Clean Drive developer junk") + XCTAssertLessThanOrEqual( + sheet.width + overlayChrome, panel.width, + "Clean Drive: PreferencesOverlay pads the sheet by 18pt a side, so it must be 36pt narrower than the panel" + ) + XCTAssertLessThanOrEqual( + sheet.height + overlayChrome, panel.height, + "Clean Drive: PreferencesOverlay pads the sheet by 18pt a side, so it must be 36pt shorter than the panel" + ) + } + /// Both tools derive from the same clamped scale, so the guarantee has to hold across its /// whole range rather than at whatever this machine reports. func testScaleStaysWithinItsDocumentedBounds() { - for scale in [KeepAwakeSizing.currentScale, FocusTimerSizing.currentScale] { + for scale in [KeepAwakeSizing.currentScale, FocusTimerSizing.currentScale, CleanDriveSizing.currentScale] { XCTAssertGreaterThanOrEqual(scale, 0.82) XCTAssertLessThanOrEqual(scale, 1.0) } From 84b1cdff585030348c7b6761fa45d58ecaf06e6c Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 23:07:51 +0530 Subject: [PATCH 2/2] Clean Drive: never offer an application's own bundled dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron applications ship their dependencies as Contents/Resources/app/node_modules with a package.json sitting right beside them, so the sibling-manifest test that keeps this feature conservative passed on them. An .app on the Desktop is exactly five components below a search root, which the depth-4 project walk reaches on its last permitted iteration, so an application the user happened to keep on the Desktop was listed as the innocuous row "app/node_modules", checked by default, and trashed on confirm. That breaks the installed application, and unlike a project's node_modules no npm install brings it back — which contradicts both the category's own detail string and the promise that the worst case here is a rebuild or a re-download. Bundles are now stepped over rather than into, and refused as an ancestor by accepts() as well, because accepts() is the gate the remover re-checks against and a fix in only one of the two would leave the last line of defence open. The extension list is deliberately wider than applications: frameworks and plug-ins carry the same layout, and a photo or Final Cut library is user data that should never be walked at all. accepts() also treated ".." as an ordinary path component. normalize() deliberately does not resolve traversal — it is a pure function and must not touch the filesystem — so containment was purely textual and "~/Documents/../.ssh/node_modules" read as living under a search root while resolving somewhere else entirely. No scanner today emits such a path, but this gate is presented as the guarantee that a stale or forged entry cannot delete the wrong thing, and against the one syntax a forged path would actually use it did not hold. Rather than teach the helpers to resolve traversal, the syntax is refused outright. Two smaller things in the panel. The Move to Trash button is live during a scan, and trashing starts a fresh one, so the cancelled scan's tail would clear isScanning and overwrite the status line with a count taken from its successor's half-filled results — the rescan ran with no progress bar, no Stop button and a wrong total. A generation counter now lets an outgoing task recognise that the state belongs to somebody else. And a wholly successful removal used to pass in silence, because presentResult returned early when there were no failures and startScan had already replaced the status line: the one path with no feedback was the one where an irreversible-looking bulk move had just succeeded. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/CleanDriveDevJunk.swift | 77 +++++++++++- .../DMonteCore/CleanDriveDevJunkView.swift | 49 ++++++-- .../CleanDriveDevJunkTests.swift | 115 ++++++++++++++++++ 3 files changed, 229 insertions(+), 12 deletions(-) diff --git a/Sources/DMonteCore/CleanDriveDevJunk.swift b/Sources/DMonteCore/CleanDriveDevJunk.swift index 989fc64..544342a 100644 --- a/Sources/DMonteCore/CleanDriveDevJunk.swift +++ b/Sources/DMonteCore/CleanDriveDevJunk.swift @@ -169,6 +169,15 @@ public enum DevJunkCategoryID: String, CaseIterable, Identifiable, Sendable { return false } + // `normalize` deliberately does not resolve ".." — it is a pure function and must not touch + // the filesystem — so containment here is purely textual. That makes + // "~/Documents/../.ssh/node_modules" look like it lives under a search root when it resolves + // somewhere else entirely. Rather than teach the helpers to resolve traversal, refuse the + // syntax: the scanner never produces it, so the only paths it can rule out are forged ones. + guard !components.contains(where: { $0 == "." || $0 == ".." }) else { + return false + } + // Nothing under a denied root is eligible unless this category's own target table spells // the path out. The Xcode and CoreSimulator caches genuinely live inside ~/Library, but a // directory merely *discovered* by walking never may. This is the backstop, not the @@ -194,10 +203,16 @@ public enum DevJunkCategoryID: String, CaseIterable, Identifiable, Sendable { // A node_modules inside another node_modules (or inside a .build, or a stray copy in a // .git object store) is already covered by its ancestor and must not be listed twice. - // Only the components below the search root are examined: the components of the home - // directory itself are not the user's project structure and must not veto a match. + // A node_modules inside a bundle is worse than redundant: Electron applications ship + // their dependencies at Contents/Resources/app/node_modules with a package.json beside + // them, so the sibling test passes and trashing it breaks an installed application that + // no `npm install` can repair. Only the components below the search root are examined: + // the components of the home directory itself are not the user's project structure and + // must not veto a match. let ancestors = components.dropLast().dropFirst(DevJunkPaths.components(root).count) - return !ancestors.contains { DevJunkPaths.prunedDirectoryNames.contains($0) } + return !ancestors.contains { + DevJunkPaths.prunedDirectoryNames.contains($0) || DevJunkPaths.isBundleName($0) + } } return fixedTargets.contains { target in @@ -260,6 +275,54 @@ public enum DevJunkPaths { ".Trash" ] + /// Extensions that make a directory a bundle — something macOS presents, and the user thinks + /// of, as a single opaque item rather than a folder to walk into. The list is deliberately wider + /// than "applications": an Electron app keeps its dependencies at + /// Contents/Resources/app/node_modules complete with a package.json, a plug-in or framework can + /// carry the same, and a photo or Final Cut library is user data that must never be walked at + /// all. Everything inside one of these is installed or authored content, never project state, so + /// deleting a piece of it breaks the item and no package manager can put it back. + static let bundleExtensions: Set = [ + "app", + "appex", + "framework", + "bundle", + "plugin", + "xpc", + "kext", + "dext", + "systemextension", + "prefpane", + "qlgenerator", + "mdimporter", + "component", + "vst", + "vst3", + "audiounit", + "docset", + "photoslibrary", + "photolibrary", + "musiclibrary", + "tvlibrary", + "imovielibrary", + "fcpbundle", + "logicx", + "band", + "sparsebundle", + "rtfd" + ] + + /// True when a single path component names a bundle. Matching is case-insensitive because the + /// boot volume is, so "Foo.APP" is the same directory as "Foo.app". A leading dot does not + /// count: ".app" is an ordinary hidden folder, not an application. + static func isBundleName(_ component: String) -> Bool { + guard let dot = component.lastIndex(of: "."), dot != component.startIndex else { + return false + } + + return bundleExtensions.contains(component[component.index(after: dot)...].lowercased()) + } + /// The only places a project-scanned category is allowed to look. Home itself is not on the /// list: walking all of ~ would be slow and would sweep in ~/Library, and a project sitting /// loose in the home directory is rare enough not to justify either cost. @@ -775,7 +838,13 @@ public enum DevJunkScanner { continue } - if DevJunkPaths.prunedDirectoryNames.contains(entry.name) || entry.name.hasPrefix(".") { + // Bundles are stepped over rather than into. An application on the Desktop is + // exactly five components from the search root, so without this the walk reaches + // an Electron app's Contents/Resources/app/node_modules on its last permitted + // iteration and offers to delete the application's own dependencies. + if DevJunkPaths.prunedDirectoryNames.contains(entry.name) + || DevJunkPaths.isBundleName(entry.name) + || entry.name.hasPrefix(".") { continue } diff --git a/Sources/DMonteCore/CleanDriveDevJunkView.swift b/Sources/DMonteCore/CleanDriveDevJunkView.swift index 68f1d2f..a377ceb 100644 --- a/Sources/DMonteCore/CleanDriveDevJunkView.swift +++ b/Sources/DMonteCore/CleanDriveDevJunkView.swift @@ -18,6 +18,10 @@ struct CleanDriveDevJunkPanel: View { @State private var isTrashing = false @State private var didStopScan = false @State private var scanTask: Task? + /// Bumped by every `startScan`. A cancelled scan's loop still has a tail to run, and trashing + /// starts a fresh scan, so the outgoing task has to be able to tell that the state it is about + /// to write belongs to somebody else now. + @State private var scanGeneration = 0 private let home = NSHomeDirectory() private let layout = DevJunkLayout.current @@ -247,6 +251,8 @@ struct CleanDriveDevJunkPanel: View { private func startScan() { scanTask?.cancel() + scanGeneration &+= 1 + let generation = scanGeneration reports = [] isScanning = true didStopScan = false @@ -256,6 +262,13 @@ struct CleanDriveDevJunkPanel: View { scanTask = Task { for await event in DevJunkScanner.scanStream(home: home) { + // Trashing restarts the scan, which cancels this task; anything still buffered in + // its stream belongs to a scan the user has moved on from and must not be written + // into the successor's state. + guard generation == scanGeneration else { + return + } + switch event { case let .progress(fraction, label): scanFraction = fraction @@ -273,6 +286,13 @@ struct CleanDriveDevJunkPanel: View { } } + // The tail is the dangerous part: a cancelled scan reaches it too, and clearing + // `isScanning` there would take the progress bar and the Stop button away from the scan + // that replaced it while announcing that scan's half-filled results as final. + guard generation == scanGeneration else { + return + } + isScanning = false scanLabel = "" // Cancelling makes the stream finish exactly as a completed scan does, so the stopped @@ -329,25 +349,38 @@ struct CleanDriveDevJunkPanel: View { } isTrashing = false - startScan() + // The result is reported before the rescan, not after: `startScan` overwrites the + // status line with "Scanning...", so a report published afterwards would be the only + // trace of a bulk move and would vanish immediately. presentResult(result) + startScan() } } private func presentResult(_ result: DevJunkRemovalResult) { - guard !result.failures.isEmpty else { + // Nothing moved and nothing failed means every entry was already gone — usually a stale + // selection from a previous pass — and there is nothing to acknowledge. + guard result.trashedCount > 0 || !result.failures.isEmpty else { return } - let details = result.failures.prefix(6).map { - "• \(DevJunkPaths.abbreviate($0.path, home: home)) — \($0.reason)" - }.joined(separator: "\n") - let more = result.failures.count > 6 ? "\n…and \(result.failures.count - 6) more" : "" - let alert = NSAlert() alert.messageText = "Moved \(result.trashedCount) item\(result.trashedCount == 1 ? "" : "s") " + "(\(result.trashedBytes.diskBytesString)) to the Trash" - alert.informativeText = "These were left in place:\n\n\(details)\(more)" + + if result.failures.isEmpty { + // A wholly successful bulk move used to pass in silence, which is the wrong ending for + // an action that looks irreversible: the user needs to be told it happened and that the + // space is still sitting in the Trash. + alert.informativeText = "The space is reclaimed once you empty the Trash." + } else { + let details = result.failures.prefix(6).map { + "• \(DevJunkPaths.abbreviate($0.path, home: home)) — \($0.reason)" + }.joined(separator: "\n") + let more = result.failures.count > 6 ? "\n…and \(result.failures.count - 6) more" : "" + alert.informativeText = "These were left in place:\n\n\(details)\(more)" + } + alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() diff --git a/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift b/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift index dd553ba..49a0536 100644 --- a/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift +++ b/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift @@ -129,6 +129,16 @@ final class CleanDriveDevJunkTests: XCTestCase { // The Docker disk image holds every image, container and volume the user owns. "~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw", "~/Library/Containers/com.docker.docker/Data", + // An installed application's own bundled dependencies. Electron apps ship + // Contents/Resources/app/node_modules with a package.json beside it, so the sibling test + // passes and only the bundle veto stands between the user and a broken application. + "~/Desktop/Cursor.app/Contents/Resources/app/node_modules", + "~/Documents/Visual Studio Code.app/Contents/Resources/app/node_modules", + "~/Developer/Widget.framework/Versions/A/Resources/.build", + // Traversal: textually inside a search root, resolving somewhere else entirely. + "~/Documents/../.ssh/node_modules", + "~/Documents/../../../etc/node_modules", + "~/Developer/./MyApp/node_modules", // Unrelated user data and system files. "~/Library/Mail", "~/Library/Application Support/MobileSync/Backup", @@ -229,6 +239,111 @@ final class CleanDriveDevJunkTests: XCTestCase { ) } + // MARK: - Bundles + + func testBundleNamesAreRecognisedAndOrdinaryFoldersAreNot() { + for name in ["Cursor.app", "Visual Studio Code.APP", "Widget.framework", "Thing.bundle", "Trip.photoslibrary"] { + XCTAssertTrue(DevJunkPaths.isBundleName(name), "\(name) should read as a bundle") + } + + for name in ["MyApp", "node_modules", "app", ".app", "notes.txt", "release.app.zip", "Package.swift"] { + XCTAssertFalse(DevJunkPaths.isBundleName(name), "\(name) should not read as a bundle") + } + } + + func testProjectCategoriesRejectAnythingInsideABundle() { + // The application's own dependencies sit beside a real package.json, so the sibling test + // that keeps this feature conservative is no defence at all here — and unlike a project's + // node_modules, this one cannot be restored by running npm install. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts( + path: "~/Desktop/Cursor.app/Contents/Resources/app/node_modules", + home: home + ) + ) + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts( + path: "~/Documents/Tools/Slack.APP/Contents/Resources/app/node_modules", + home: home + ) + ) + XCTAssertFalse( + DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Developer/Thing.bundle/Contents/.build", home: home) + ) + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "~/Documents/Trip.photoslibrary/private/node_modules", home: home) + ) + + // A folder that merely has a dot in its name is still an ordinary project folder. + XCTAssertTrue(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/my.site/node_modules", home: home)) + } + + func testScanLeavesAnApplicationsOwnDependenciesAlone() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + + // Exactly the Electron layout, on the Desktop, where the walk's depth cap still reaches it: + // Desktop/Cursor.app/Contents/Resources/app/node_modules is five levels below the root. + let bundledApp = DevJunkPaths.joined(sandbox, "Desktop/Cursor.app/Contents/Resources/app") + try writeFile(DevJunkPaths.joined(bundledApp, "package.json")) + try writeFile(DevJunkPaths.joined(bundledApp, "node_modules/electron-log/index.js")) + + // A genuine project on the same Desktop still has to be found, or the veto is too wide. + try writeFile(DevJunkPaths.joined(sandbox, "Desktop/Site/package.json")) + try writeFile(DevJunkPaths.joined(sandbox, "Desktop/Site/node_modules/left-pad/index.js")) + + let entries = DevJunkScanner.entries(for: .nodeModules, home: sandbox) + let paths = Set(entries.map(\.path)) + + XCTAssertFalse(paths.contains(DevJunkPaths.joined(bundledApp, "node_modules"))) + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Desktop/Site/node_modules"))) + + // The pre-delete gate is the last line of defence, so it must refuse the bundled copy too + // even if some future scanner change hands it over. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts( + path: DevJunkPaths.joined(bundledApp, "node_modules"), + home: sandbox + ) + ) + } + + // MARK: - Path traversal + + func testAcceptsRejectsPathsContainingTraversalComponents() { + // normalize() does not resolve "..", so containment is textual: without this rule + // "~/Documents/../.ssh/node_modules" reads as living under the Documents search root while + // resolving into ~/.ssh, and "../../../etc" escapes home entirely. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "/Users/tester/Documents/../.ssh/node_modules", home: home)) + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "/Users/tester/Documents/../../../etc/node_modules", home: home) + ) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "/Users/tester/Code/../Library/.build", home: home)) + XCTAssertFalse( + DevJunkCategoryID.xcodeDerivedData.accepts( + path: "/Users/tester/Library/Developer/Xcode/DerivedData/../../../Mail", + home: home + ) + ) + // "." is harmless in itself but arrives from the same class of unnormalised input, and + // accepting it would mean two spellings of one path disagree about eligibility. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "/Users/tester/Developer/./App/node_modules", home: home)) + } + + func testValidateRejectsATraversalPathBeforeItReachesTheRemover() { + let forged = DevJunkEntry( + category: .nodeModules, + path: "/Users/tester/Documents/../.ssh/node_modules", + size: 999 + ) + let genuine = DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/App/node_modules", size: 10) + + let (allowed, rejected) = DevJunkRemover.validate([forged, genuine], home: home) + + XCTAssertEqual(allowed, [genuine]) + XCTAssertEqual(rejected, [forged]) + } + func testEveryCategoryStaysInsideTheUsersHomeDirectory() { for category in DevJunkCategoryID.allCases { for root in category.allowedRoots(home: home) {