From 4efb6a2a127c75182e8fc8aeade86fee7011eccf Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 13:02:36 +0530 Subject: [PATCH 1/2] Uninstall Apps: actually remove the support files an app leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trashing the bundle was only ever half an uninstall — the app's Application Support, Caches, Preferences, Container, Saved Application State, Logs, HTTPStorages and WebKit directories stayed on disk in both ~/Library and /Library, which is the whole reason people reach for a third-party uninstaller. The sweep is a data-loss risk aimed at directories the tool did not create, so the rules that decide what it touches live in `UninstallerLeftovers` as pure, nonisolated functions and are shaped around two independent guarantees. First, derivation is exact: a leaf is only ever `/`, with no prefix, glob or substring matching anywhere, so uninstalling `com.example.app` cannot reach `com.example.app.helper`. Second, removal is fenced: `isRemovable(_:allowedRoots:)` re-derives the verdict from the URL alone immediately before the trash call, and a path passes only if it is a direct child of a known Library container *and* still resolves inside one after symlinks are followed. A planted `~/Library/Caches/com.evil.app -> ~/Documents` fails the second half even though it passes the first, which is precisely the case a lexical check cannot see. Roots shallower than three path components are ignored on principle, so no call sequence can point the feature at a directory whose children are volumes or user accounts. Bundle identifiers come out of Info.plist files this app did not write, so they are treated as untrusted input about to be pasted into a path: separators, control characters, leading dots and leading tildes are refused outright rather than escaped, because a refused identifier costs the user a manual cleanup while a mis-escaped one costs them a directory. Unicode, spaces and dots are the normal case and pass through untouched. The display-name fallback exists for the few apps whose bundle identifier cannot be read, and it is the one path where a wrong answer deletes a *different* app's data. It is therefore restricted to the three directories macOS ever names after an app rather than after its identifier, refused for names shorter than four characters, refused for umbrella directories several vendors share (Application Support/Google is Chrome's and Drive's and Earth's), refused when another installed app answers to the same name, and — even when it is allowed — its rows open unticked so nothing is ever removed on a guess the user did not look at. Everything moves to the Trash rather than being unlinked, and the old single-line confirmation alert is replaced by a review sheet listing every item with its real path and size, because a sweep that is a heuristic by nature has to be recoverable and has to be seen before it runs. The bundle is trashed before the leftovers so that a refusal on the bundle leaves a working install rather than a gutted one. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/Uninstaller.swift | 266 ++++++++- Sources/DMonteCore/UninstallerLeftovers.swift | 517 ++++++++++++++++++ .../UninstallerLeftoversTests.swift | 454 +++++++++++++++ 3 files changed, 1223 insertions(+), 14 deletions(-) create mode 100644 Sources/DMonteCore/UninstallerLeftovers.swift create mode 100644 Tests/DMonteCoreTests/UninstallerLeftoversTests.swift diff --git a/Sources/DMonteCore/Uninstaller.swift b/Sources/DMonteCore/Uninstaller.swift index 3e384bd..923c5de 100644 --- a/Sources/DMonteCore/Uninstaller.swift +++ b/Sources/DMonteCore/Uninstaller.swift @@ -128,6 +128,11 @@ public struct UninstallerPopoverView: View { @State private var isSizing = false @State private var scanToken = UUID() @State private var errorMessage: String? + @State private var statusMessage: String? + @State private var review: LeftoverReview? + @State private var selectedLeftoverIDs: Set = [] + @State private var isPreparingRemoval = false + @State private var isRemoving = false public init(onQuit: @escaping () -> Void) { self.onQuit = onQuit @@ -166,6 +171,15 @@ public struct UninstallerPopoverView: View { .task { await reload() } + .sheet(item: $review) { pendingReview in + LeftoverReviewSheet( + app: pendingReview.app, + items: pendingReview.items, + selection: $selectedLeftoverIDs, + onCancel: { review = nil }, + onConfirm: { performUninstall(pendingReview) } + ) + } } private var header: some View { @@ -265,7 +279,7 @@ public struct UninstallerPopoverView: View { Divider() - Text("Move this app bundle to Trash. This does not search for related support files yet.") + Text("Moves the app bundle to Trash, then offers the support files it left in ~/Library and /Library. You review the exact list before anything moves.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) @@ -273,6 +287,10 @@ public struct UninstallerPopoverView: View { Text(errorMessage) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.red) + } else if let statusMessage { + Text(statusMessage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) } Spacer() @@ -286,12 +304,18 @@ public struct UninstallerPopoverView: View { Spacer() + if isPreparingRemoval || isRemoving { + ProgressView() + .controlSize(.small) + } + Button(role: .destructive) { - uninstall(selectedApp) + beginUninstall(selectedApp) } label: { Label("Move to Trash", systemImage: "trash") } .keyboardShortcut(.delete, modifiers: [.command]) + .disabled(isPreparingRemoval || isRemoving) } } else { Spacer() @@ -375,7 +399,11 @@ public struct UninstallerPopoverView: View { } } - private func uninstall(_ app: InstalledApplication) { + /// Gathers everything that would move — the bundle and its leftovers — and hands it to the + /// review sheet. Nothing is removed on this path: the old single-alert confirmation could + /// only ever describe the bundle, and a leftover sweep the user has not seen item by item is + /// exactly the kind of "helpful" deletion this tool must not do. + private func beginUninstall(_ app: InstalledApplication) { if app.isRunning { let runningAlert = NSAlert() runningAlert.messageText = "\(app.name) is currently running" @@ -399,26 +427,236 @@ public struct UninstallerPopoverView: View { } } - let alert = NSAlert() - alert.messageText = "Move \(app.name) to Trash?" - alert.informativeText = app.path - alert.alertStyle = .warning - alert.addButton(withTitle: "Move to Trash") - alert.addButton(withTitle: "Cancel") + errorMessage = nil + statusMessage = nil + isPreparingRemoval = true + + // The names of the *other* installed apps are what lets the display-name fallback refuse + // an ambiguous guess, so they have to be sampled here where the app list lives. + let otherAppNames = apps.filter { $0.id != app.id }.map(\.name) + let bundleIdentifier = app.bundleIdentifier + let appName = app.name + + Task { + let items = await Task.detached(priority: .userInitiated) { + LeftoverScanner.scan( + bundleIdentifier: bundleIdentifier, + appName: appName, + otherAppNames: otherAppNames + ) + }.value - guard alert.runModal() == .alertFirstButtonReturn else { - return + isPreparingRemoval = false + selectedLeftoverIDs = UninstallerLeftovers.defaultSelection(for: items) + review = LeftoverReview(app: app, items: items) } + } + + /// Trashes the bundle first, then whatever the user left ticked. The bundle goes first on + /// purpose: if macOS refuses it (a locked bundle, a missing admin right) the support files + /// are still where the app expects them, so the user is left with a working install rather + /// than a half-gutted one. + private func performUninstall(_ pendingReview: LeftoverReview) { + let app = pendingReview.app + let chosen = pendingReview.items.filter { selectedLeftoverIDs.contains($0.id) } + + review = nil do { var resultingURL: NSURL? try FileManager.default.trashItem(at: app.url, resultingItemURL: &resultingURL) - apps.removeAll { $0.id == app.id } - selectedAppID = filteredApps.first?.id - errorMessage = nil } catch { errorMessage = error.localizedDescription + return + } + + apps.removeAll { $0.id == app.id } + selectedAppID = filteredApps.first?.id + errorMessage = nil + + guard !chosen.isEmpty else { + statusMessage = "Moved \(app.name) to Trash." + return + } + + isRemoving = true + + Task { + let result = await Task.detached(priority: .userInitiated) { + LeftoverScanner.moveToTrash(chosen) + }.value + + isRemoving = false + statusMessage = "Moved \(app.name) and \(result.trashedCount) support \(result.trashedCount == 1 ? "item" : "items") (\(result.trashedBytes.bytesString)) to Trash." + + if let firstFailure = result.failures.first { + errorMessage = result.failures.count == 1 + ? "Could not remove \(firstFailure.path): \(firstFailure.reason)" + : "Could not remove \(result.failures.count) support items. First: \(firstFailure.path) — \(firstFailure.reason)" + } + } + } +} + +/// The bundle and its leftovers, frozen at the moment the user asked to uninstall so the sheet +/// cannot be re-scanned out from under the checkboxes the user just ticked. +private struct LeftoverReview: Identifiable { + let id = UUID() + let app: InstalledApplication + let items: [LeftoverItem] +} + +/// The "here is exactly what will move" sheet. Every leftover is listed with its real path and +/// size, and rows matched only by display name start unticked because that match is a heuristic. +private struct LeftoverReviewSheet: View { + var app: InstalledApplication + var items: [LeftoverItem] + @Binding var selection: Set + var onCancel: () -> Void + var onConfirm: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Move \(app.name) to Trash?") + .font(.system(size: 17, weight: .bold)) + + HStack(spacing: 12) { + AppIcon(url: app.url, size: 40) + + VStack(alignment: .leading, spacing: 2) { + Text(app.path) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + + Text("The app bundle itself. Always removed.") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + } + + Spacer(minLength: 0) + + Text(app.size?.bytesString ?? "—") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) + } + + Divider() + + if items.isEmpty { + Text("No support files were found for this app.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack { + Text("Support files (\(items.count))") + .font(.system(size: 13, weight: .semibold)) + + Spacer() + + Button(allSelected ? "Deselect All" : "Select All") { + selection = allSelected ? [] : Set(items.map(\.id)) + } + .buttonStyle(.link) + } + + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(items) { item in + row(for: item) + } + } + } + .frame(height: 220) + } + + Divider() + + HStack { + Text(totalLabel) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + Spacer() + + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + + Button("Move to Trash", action: onConfirm) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 560) + } + + private func row(for item: LeftoverItem) -> some View { + HStack(alignment: .top, spacing: 10) { + Toggle("", isOn: binding(for: item)) + .labelsHidden() + .toggleStyle(.checkbox) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(item.candidate.label) + .font(.system(size: 12, weight: .semibold)) + + if item.candidate.match == .appName { + Text("matched by name") + .font(.system(size: 10, weight: .bold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.orange.opacity(0.22)) + .clipShape(Capsule()) + } + + if item.candidate.scope == .system { + Text("system-wide") + .font(.system(size: 10, weight: .bold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.18)) + .clipShape(Capsule()) + } + } + + Text(item.candidate.displayPath) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + + Text(item.bytes.bytesString) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) } + .padding(.vertical, 2) + } + + private func binding(for item: LeftoverItem) -> Binding { + Binding( + get: { selection.contains(item.id) }, + set: { isOn in + if isOn { + selection.insert(item.id) + } else { + selection.remove(item.id) + } + } + ) + } + + private var allSelected: Bool { + !items.isEmpty && selection.count == items.count + } + + private var totalLabel: String { + let total = (app.size ?? 0) + UninstallerLeftovers.selectedBytes(in: items, selection: selection) + return "\(total.bytesString) will move to Trash" } } diff --git a/Sources/DMonteCore/UninstallerLeftovers.swift b/Sources/DMonteCore/UninstallerLeftovers.swift new file mode 100644 index 0000000..4ee95c4 --- /dev/null +++ b/Sources/DMonteCore/UninstallerLeftovers.swift @@ -0,0 +1,517 @@ +import Foundation + +/// One of the directories macOS lets an application scatter per-app state into, described as a +/// *rule* ("a child of `/Caches` named after the app") rather than as a finished path. +/// Keeping it a rule is what lets the same eight entries cover both `~/Library` and `/Library` +/// without a second, drift-prone copy of the list. +public struct LeftoverLocation: Sendable, Hashable { + /// Path of the container directory relative to a Library root, e.g. `Application Support`. + public let subpath: String + + /// Appended to the identifier to form the leaf name. macOS spells two of these with an + /// extension (`…​.plist`, `…​.savedState`); the rest are plain directories. + public let suffix: String + + /// Human label for the review sheet. + public let label: String + + /// Whether a leaf here is ever named after the app's *display name* rather than its bundle + /// identifier. Only Application Support, Caches and Logs are — Containers, HTTPStorages, + /// WebKit, Saved Application State and Preferences are keyed by bundle identifier by macOS + /// itself, so a display-name guess in those could only ever land on another app's data. + public let supportsNameFallback: Bool + + public init(subpath: String, suffix: String = "", label: String, supportsNameFallback: Bool = false) { + self.subpath = subpath + self.suffix = suffix + self.label = label + self.supportsNameFallback = supportsNameFallback + } +} + +/// Which Library a candidate came from. Shown in the review sheet because removing something out +/// of `/Library` is a machine-wide change and usually needs an admin prompt, whereas `~/Library` +/// is the user's own data. +public enum LeftoverScope: String, Sendable, Hashable { + case user + case system + + public var label: String { + switch self { + case .user: "~/Library" + case .system: "/Library" + } + } +} + +/// How a candidate was matched. This is not cosmetic: it decides whether the row is ticked when +/// the review sheet opens. Bundle-identifier matches are exact and safe to pre-select; display +/// name matches are a heuristic and start unticked so nothing is ever removed on a guess the +/// user did not look at. +public enum LeftoverMatch: String, Sendable, Hashable { + case bundleIdentifier + case appName +} + +/// A path that *might* hold leftovers for the app being uninstalled. Derivation is pure — nothing +/// here has looked at the disk yet. +public struct LeftoverCandidate: Identifiable, Sendable, Hashable { + public let url: URL + public let label: String + public let scope: LeftoverScope + public let match: LeftoverMatch + + public var id: String { url.path } + + public init(url: URL, label: String, scope: LeftoverScope, match: LeftoverMatch) { + self.url = url + self.label = label + self.scope = scope + self.match = match + } + + /// Path as the user thinks of it, with their home directory written as `~`. + public var displayPath: String { + (url.path as NSString).abbreviatingWithTildeInPath + } +} + +/// A candidate that was found on disk, with its measured size. +public struct LeftoverItem: Identifiable, Sendable, Hashable { + public let candidate: LeftoverCandidate + public let bytes: UInt64 + + public var id: String { candidate.id } + public var url: URL { candidate.url } + + /// Ticked when the review sheet opens. See `LeftoverMatch`. + public var isSelectedByDefault: Bool { + candidate.match == .bundleIdentifier + } + + public init(candidate: LeftoverCandidate, bytes: UInt64) { + self.candidate = candidate + self.bytes = bytes + } +} + +/// Something that survived the safety check but that macOS still refused to move. +public struct LeftoverRemovalFailure: Sendable, Equatable { + public let path: String + public let reason: String + + public init(path: String, reason: String) { + self.path = path + self.reason = reason + } +} + +public struct LeftoverRemovalResult: Sendable, Equatable { + public var trashedCount: Int + public var trashedBytes: UInt64 + public var failures: [LeftoverRemovalFailure] + + public init(trashedCount: Int = 0, trashedBytes: UInt64 = 0, failures: [LeftoverRemovalFailure] = []) { + self.trashedCount = trashedCount + self.trashedBytes = trashedBytes + self.failures = failures + } +} + +/// Path derivation and the safety predicate for the Uninstaller's leftover sweep. +/// +/// This is the dangerous part of the tool, so it is deliberately pure and `nonisolated`: every +/// rule below is a function of its arguments and is exercised directly by the test target, with +/// no window, no app list and no privileged state involved. +/// +/// Two independent ideas keep it safe, and both must agree before anything is offered: +/// +/// 1. **Derivation is exact.** A leaf is only ever `/`, where +/// the identifier is the app's bundle identifier (or, in the three directories that use one, +/// its display name). There is no prefix, glob or substring matching anywhere, so +/// `com.example.app` can never pull in `com.example.app.helper`'s data. +/// 2. **Removal is fenced.** `isRemovable(_:allowedRoots:)` re-derives the verdict from the URL +/// alone immediately before the trash call. A path only passes if it is a *direct child* of a +/// known Library container **and** still resolves inside one after symlinks are followed, so a +/// traversal attempt or a planted symlink pointing at the user's documents is refused rather +/// than followed. +public enum UninstallerLeftovers { + + // MARK: - The known locations + + /// The eight directories swept, in the order they are shown. Anything not in this list is, + /// by construction, not reachable by this feature. + public static let locations: [LeftoverLocation] = [ + LeftoverLocation(subpath: "Application Support", label: "Application Support", supportsNameFallback: true), + LeftoverLocation(subpath: "Caches", label: "Caches", supportsNameFallback: true), + LeftoverLocation(subpath: "Preferences", suffix: ".plist", label: "Preferences"), + LeftoverLocation(subpath: "Containers", label: "Container"), + LeftoverLocation(subpath: "Saved Application State", suffix: ".savedState", label: "Saved Application State"), + LeftoverLocation(subpath: "Logs", label: "Logs", supportsNameFallback: true), + LeftoverLocation(subpath: "HTTPStorages", label: "HTTP Storage"), + LeftoverLocation(subpath: "WebKit", label: "WebKit Data") + ] + + public static var defaultUserLibrary: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library") + } + + public static let defaultSystemLibrary = URL(fileURLWithPath: "/Library") + + /// Every directory this feature is allowed to remove a child from. Both Libraries are folded + /// into one flat list because the safety predicate only ever asks "is this URL a child of one + /// of these?" — it does not care which Library the answer came from. + public static func allowedRoots(userLibrary: URL, systemLibrary: URL) -> [URL] { + [userLibrary, systemLibrary].flatMap { library in + locations.map { library.appendingPathComponent($0.subpath) } + } + } + + // MARK: - Identifier hygiene + + /// Bundle identifiers arrive from `Info.plist` files this app did not write, so they are + /// untrusted input that is about to be pasted into a filesystem path. Anything that could + /// change the *shape* of that path — a separator, a relative reference, a control character — + /// is rejected outright rather than escaped, because a rejected identifier costs the user a + /// manual cleanup while a mis-escaped one costs them a directory. + public static func sanitizedIdentifier(_ raw: String?) -> String? { + guard let raw else { + return nil + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + return nil + } + + // A single path component cannot exceed 255 bytes on APFS/HFS+, so a longer identifier + // could not name a real directory anyway. + guard trimmed.utf8.count <= 255 else { + return nil + } + + // "/" is the obvious traversal vector; ":" is the classic HFS separator and is still + // translated by some Carbon-era paths. + guard !trimmed.contains("/"), !trimmed.contains(":") else { + return nil + } + + guard trimmed.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { + return nil + } + + // Covers "." and ".." as well as hidden names. No shipping bundle identifier starts with + // a dot, so refusing them costs nothing and removes a whole class of surprise. + guard trimmed.first != "." else { + return nil + } + + // `appendingPathComponent` treats a leading "~" as an ordinary character, but + // `NSString.expandingTildeInPath` — which any future caller might reasonably reach for — + // turns it into the home directory. Refusing it here means that difference can never + // become a bug. + guard trimmed.first != "~" else { + return nil + } + + return trimmed + } + + /// Directory names that hold more than one vendor's — or more than one app's — data. The + /// display-name fallback must never produce one of these, because `~/Library/Application + /// Support/Google` is Chrome's *and* Drive's *and* Earth's, and uninstalling any one of them + /// must not take the other two with it. + private static let sharedDirectoryNames: Set = [ + "adobe", "apple", "google", "microsoft", "mozilla", "steam", "unity", "jetbrains", + "app", "apps", "application", "applications", "backup", "backups", "cache", "caches", + "common", "crashreporter", "data", "default", "defaults", "desktop", "documents", + "downloads", "files", "helper", "helpers", "home", "info", "install", "installer", + "library", "log", "logs", "mail", "main", "media", "music", "notes", "photos", + "preferences", "private", "public", "setup", "shared", "support", "system", "temp", + "test", "tests", "tmp", "update", "updater", "user", "users", "utilities", "utility" + ] + + /// Whether the app's display name is specific enough to guess a directory from. + /// + /// The fallback only exists for the handful of apps whose bundle identifier cannot be read, + /// and it is the one place a wrong answer deletes a *different* app's data, so three things + /// have to hold: the name has to survive the same path hygiene a bundle identifier does, it + /// has to be long enough that a collision is unlikely (three characters or fewer is a + /// coin-flip: "Go", "IDE", "Zip"), and it must not be a directory that is known to be shared. + /// Finally, if another installed app answers to the same name the guess is ambiguous by + /// definition and is refused. + public static func isNameSpecificEnough(_ rawName: String, otherAppNames: [String] = []) -> Bool { + guard let name = sanitizedIdentifier(rawName) else { + return false + } + + guard name.count >= 4 else { + return false + } + + let folded = normalized(name) + + guard !sharedDirectoryNames.contains(folded) else { + return false + } + + return !otherAppNames.contains { normalized($0) == folded } + } + + private static func normalized(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } + + // MARK: - Derivation + + /// Every place the app being uninstalled *could* have left something. Nothing here touches + /// the disk; `LeftoverScanner` is what decides which of these actually exist. + /// + /// Bundle-identifier candidates come first and cover all eight locations. Display-name + /// candidates are appended only for the three directories that are ever named that way, and + /// only when the name clears `isNameSpecificEnough`. + public static func candidates( + bundleIdentifier: String?, + appName: String, + userLibrary: URL, + systemLibrary: URL, + otherAppNames: [String] = [] + ) -> [LeftoverCandidate] { + let libraries: [(scope: LeftoverScope, url: URL)] = [ + (.user, userLibrary), + (.system, systemLibrary) + ] + + let identifier = sanitizedIdentifier(bundleIdentifier) + let nameIdentifier = isNameSpecificEnough(appName, otherAppNames: otherAppNames) + ? sanitizedIdentifier(appName) + : nil + let roots = allowedRoots(userLibrary: userLibrary, systemLibrary: systemLibrary) + + var candidates: [LeftoverCandidate] = [] + var seenPaths = Set() + + func append(_ leafIdentifier: String, match: LeftoverMatch, onlyNameable: Bool) { + for library in libraries { + for location in locations where !onlyNameable || location.supportsNameFallback { + let url = library.url + .appendingPathComponent(location.subpath) + .appendingPathComponent(leafIdentifier + location.suffix) + + // Belt and braces: derivation is exact, but the leaf still has to satisfy the + // same fence that gates the trash call. If the two ever disagree, the + // candidate is dropped here rather than surfacing as an un-removable row. + guard isRemovable(url, allowedRoots: roots) else { + continue + } + + guard seenPaths.insert(url.standardizedFileURL.path).inserted else { + continue + } + + candidates.append( + LeftoverCandidate(url: url, label: location.label, scope: library.scope, match: match) + ) + } + } + } + + if let identifier { + append(identifier, match: .bundleIdentifier, onlyNameable: false) + } + + if let nameIdentifier, nameIdentifier != identifier { + append(nameIdentifier, match: .appName, onlyNameable: true) + } + + return candidates + } + + // MARK: - The safety predicate + + /// The single gate every removal passes through, and the only thing standing between a + /// malformed bundle identifier and someone's home directory. + /// + /// A URL is removable only when **both** hold: + /// + /// - Lexically it is a direct child of one of `allowedRoots`. Anything with `..` in it + /// collapses during standardisation and lands somewhere that is not a known root, so a + /// traversal attempt fails this half. + /// - After symlinks are resolved on both the URL and the roots, it is still inside a root. + /// A planted `~/Library/Caches/com.evil.app -> ~/Documents` therefore fails this half even + /// though it passes the first, which is exactly the case the first half cannot see. + /// + /// Roots shallower than three path components (`/`, `/Users`, `/Library`) are ignored on + /// principle: no matter how this is called, the feature can never be pointed at a directory + /// whose children are whole volumes, user accounts or system frameworks. + public static func isRemovable(_ url: URL, allowedRoots: [URL]) -> Bool { + let standardized = url.standardizedFileURL + let leafName = standardized.lastPathComponent + + guard !leafName.isEmpty, leafName != "/", leafName != ".", leafName != ".." else { + return false + } + + // A root is allowed to be spelled two ways, because `/var`, `/tmp` and `/etc` are symlinks + // into `/private` and Foundation is inconsistent about which spelling it hands back. Both + // spellings name the same directory, so accepting either is not a widening of the fence — + // whereas accepting only one would make the resolved-path half below fail on paths that + // are perfectly legitimate. + let rootIdentities = allowedRoots + .map(\.standardizedFileURL) + .filter { $0.pathComponents.count >= 3 } + .flatMap { [$0, $0.resolvingSymlinksInPath().standardizedFileURL] } + + guard !rootIdentities.isEmpty else { + return false + } + + let parentPath = standardized.deletingLastPathComponent().standardizedFileURL.path + guard rootIdentities.contains(where: { $0.path == parentPath }) else { + return false + } + + // Deliberately the *resolved* URL only. Checking the unresolved spelling as a fallback + // here would hand a planted symlink the pass this half exists to deny it. + let resolved = standardized.resolvingSymlinksInPath().standardizedFileURL + return rootIdentities.contains { isStrictlyContained(resolved, in: $0) } + } + + /// Containment compared component-by-component rather than by string prefix, so that + /// `/Library/CachesElsewhere/x` is not mistaken for a child of `/Library/Caches`. + private static func isStrictlyContained(_ url: URL, in root: URL) -> Bool { + let rootComponents = root.pathComponents + let urlComponents = url.pathComponents + + guard urlComponents.count > rootComponents.count else { + return false + } + + return Array(urlComponents.prefix(rootComponents.count)) == rootComponents + } + + // MARK: - Selection arithmetic + + /// IDs ticked when the review sheet opens: exact bundle-identifier matches only. + public static func defaultSelection(for items: [LeftoverItem]) -> Set { + Set(items.filter(\.isSelectedByDefault).map(\.id)) + } + + /// Total of the ticked rows, so the sheet can say how much is about to move. + public static func selectedBytes(in items: [LeftoverItem], selection: Set) -> UInt64 { + items.reduce(UInt64(0)) { total, item in + selection.contains(item.id) ? total + item.bytes : total + } + } +} + +/// The I/O half: which candidates exist, how big they are, and moving the chosen ones to Trash. +/// +/// Every entry point re-runs `UninstallerLeftovers.isRemovable` against freshly computed roots +/// rather than trusting the caller's list, so a stale or hand-built `LeftoverItem` cannot become +/// a delete. +public enum LeftoverScanner { + + public static func scan( + bundleIdentifier: String?, + appName: String, + otherAppNames: [String] = [], + userLibrary: URL = UninstallerLeftovers.defaultUserLibrary, + systemLibrary: URL = UninstallerLeftovers.defaultSystemLibrary + ) -> [LeftoverItem] { + let candidates = UninstallerLeftovers.candidates( + bundleIdentifier: bundleIdentifier, + appName: appName, + userLibrary: userLibrary, + systemLibrary: systemLibrary, + otherAppNames: otherAppNames + ) + + return candidates.compactMap { candidate in + guard exists(at: candidate.url) else { + return nil + } + + return LeftoverItem(candidate: candidate, bytes: size(of: candidate.url)) + } + } + + /// Moves the given items to Trash — never `removeItem`. A leftover sweep is a heuristic by + /// nature, and the Trash is what makes a wrong guess a nuisance instead of a data loss. + public static func moveToTrash( + _ items: [LeftoverItem], + userLibrary: URL = UninstallerLeftovers.defaultUserLibrary, + systemLibrary: URL = UninstallerLeftovers.defaultSystemLibrary + ) -> LeftoverRemovalResult { + let roots = UninstallerLeftovers.allowedRoots(userLibrary: userLibrary, systemLibrary: systemLibrary) + var result = LeftoverRemovalResult() + + for item in items { + guard UninstallerLeftovers.isRemovable(item.url, allowedRoots: roots) else { + result.failures.append( + LeftoverRemovalFailure( + path: item.candidate.displayPath, + reason: "outside the app-support folders this tool is allowed to touch" + ) + ) + continue + } + + do { + var resultingURL: NSURL? + try FileManager.default.trashItem(at: item.url, resultingItemURL: &resultingURL) + result.trashedCount += 1 + result.trashedBytes += item.bytes + } catch { + result.failures.append( + LeftoverRemovalFailure(path: item.candidate.displayPath, reason: error.localizedDescription) + ) + } + } + + return result + } + + /// `fileExists` follows symlinks, which would hide a dangling link and — worse — report a + /// link to somewhere else as present. `attributesOfItem` does not follow, so a symlink is + /// reported on its own terms and the safety predicate gets to rule on where it points. + private static func exists(at url: URL) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: url.path)) != nil + } + + /// Allocated size on disk. Symlinks are counted as themselves: the enumerator does not + /// descend through them, so a link's target is never billed to the app being uninstalled. + private static func size(of url: URL) -> UInt64 { + let sizeKeys: Set = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return 0 + } + + if !isDirectory.boolValue { + let values = try? url.resourceValues(forKeys: sizeKeys) + return UInt64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + } + + guard let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: Array(sizeKeys), + options: [] + ) else { + return 0 + } + + return enumerator.reduce(UInt64(0)) { partialResult, entry in + guard let fileURL = entry as? URL, + let values = try? fileURL.resourceValues(forKeys: sizeKeys) else { + return partialResult + } + + return partialResult + UInt64(values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0) + } + } +} diff --git a/Tests/DMonteCoreTests/UninstallerLeftoversTests.swift b/Tests/DMonteCoreTests/UninstallerLeftoversTests.swift new file mode 100644 index 0000000..a7ba405 --- /dev/null +++ b/Tests/DMonteCoreTests/UninstallerLeftoversTests.swift @@ -0,0 +1,454 @@ +import XCTest +@testable import DMonteCore + +/// The leftover sweep is the one part of the Uninstaller that can destroy data belonging to an app +/// the user did not ask to remove, so the rules that decide *what* it touches are tested far more +/// aggressively than the feature is large. +/// +/// Everything here runs against synthetic Library roots inside a per-test temporary directory — +/// the real `~/Library` and `/Library` are never read, and nothing is ever actually moved to the +/// Trash. The removal tests only exercise the refusal path, which is the half that matters. +final class UninstallerLeftoversTests: XCTestCase { + + // MARK: - Fixtures + + private var sandbox: URL! + + /// Stands in for `~/Library`. + private var userLibrary: URL { sandbox.appendingPathComponent("Library") } + + /// Stands in for `/Library`. + private var systemLibrary: URL { sandbox.appendingPathComponent("SystemLibrary") } + + private var roots: [URL] { + UninstallerLeftovers.allowedRoots(userLibrary: userLibrary, systemLibrary: systemLibrary) + } + + override func setUpWithError() throws { + try super.setUpWithError() + + sandbox = FileManager.default.temporaryDirectory + .appendingPathComponent("UninstallerLeftoversTests-\(UUID().uuidString)") + + for library in [userLibrary, systemLibrary] { + for location in UninstallerLeftovers.locations { + try FileManager.default.createDirectory( + at: library.appendingPathComponent(location.subpath), + withIntermediateDirectories: true + ) + } + } + } + + override func tearDownWithError() throws { + if let sandbox, FileManager.default.fileExists(atPath: sandbox.path) { + try FileManager.default.removeItem(at: sandbox) + } + sandbox = nil + try super.tearDownWithError() + } + + private func candidates( + bundleIdentifier: String?, + appName: String = "zz", + otherAppNames: [String] = [] + ) -> [LeftoverCandidate] { + UninstallerLeftovers.candidates( + bundleIdentifier: bundleIdentifier, + appName: appName, + userLibrary: userLibrary, + systemLibrary: systemLibrary, + otherAppNames: otherAppNames + ) + } + + private func scan(bundleIdentifier: String?, appName: String = "zz", otherAppNames: [String] = []) -> [LeftoverItem] { + LeftoverScanner.scan( + bundleIdentifier: bundleIdentifier, + appName: appName, + otherAppNames: otherAppNames, + userLibrary: userLibrary, + systemLibrary: systemLibrary + ) + } + + /// Creates a directory holding one file of `bytes` bytes, so sizing has something to measure. + @discardableResult + private func makeLeaf(_ library: URL, _ subpath: String, _ leaf: String, bytes: Int = 64) throws -> URL { + let url = library.appendingPathComponent(subpath).appendingPathComponent(leaf) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + try Data(repeating: 0x41, count: bytes).write(to: url.appendingPathComponent("payload.bin")) + return url + } + + // MARK: - Derivation covers exactly the documented locations + + func testBundleIdentifierCoversEveryLocationInBothLibraries() { + let derived = candidates(bundleIdentifier: "com.example.app") + + XCTAssertEqual(derived.count, UninstallerLeftovers.locations.count * 2) + + let paths = Set(derived.map(\.url.path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Application Support/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Caches/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Preferences/com.example.app.plist").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Containers/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Saved Application State/com.example.app.savedState").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Logs/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("HTTPStorages/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("WebKit/com.example.app").path)) + XCTAssertTrue(paths.contains(systemLibrary.appendingPathComponent("Application Support/com.example.app").path)) + XCTAssertTrue(paths.contains(systemLibrary.appendingPathComponent("Preferences/com.example.app.plist").path)) + } + + func testEveryDerivedCandidateSatisfiesTheRemovalFence() { + for candidate in candidates(bundleIdentifier: "com.example.app") { + XCTAssertTrue( + UninstallerLeftovers.isRemovable(candidate.url, allowedRoots: roots), + "derivation produced a path the fence would refuse: \(candidate.url.path)" + ) + } + } + + func testBundleIdentifierCandidatesAreSelectedByDefault() { + let items = candidates(bundleIdentifier: "com.example.app") + .map { LeftoverItem(candidate: $0, bytes: 10) } + + XCTAssertEqual(UninstallerLeftovers.defaultSelection(for: items).count, items.count) + } + + // MARK: - Identifier hygiene + + func testDottedIdentifierIsUsedVerbatim() { + let derived = candidates(bundleIdentifier: "com.company.Product.v2") + + XCTAssertEqual( + derived.first { $0.label == "Caches" && $0.scope == .user }?.url.lastPathComponent, + "com.company.Product.v2" + ) + } + + func testIdentifierWithSpacesIsAccepted() { + let derived = candidates(bundleIdentifier: "com.example.My Great App") + + XCTAssertEqual(derived.count, UninstallerLeftovers.locations.count * 2) + XCTAssertEqual( + derived.first { $0.label == "Application Support" && $0.scope == .user }?.url.lastPathComponent, + "com.example.My Great App" + ) + } + + func testUnicodeIdentifierIsAcceptedAndNotMangled() { + let identifier = "com.exämple.日本語アプリ.Ω" + let derived = candidates(bundleIdentifier: identifier) + + XCTAssertEqual(derived.count, UninstallerLeftovers.locations.count * 2) + XCTAssertEqual( + derived.first { $0.label == "Preferences" && $0.scope == .user }?.url.lastPathComponent, + identifier + ".plist" + ) + } + + func testSurroundingWhitespaceIsTrimmedRatherThanBakedIntoThePath() { + XCTAssertEqual(UninstallerLeftovers.sanitizedIdentifier(" com.example.app\n"), "com.example.app") + } + + func testTraversalIdentifiersAreRefused() { + for hostile in ["../..", "../../etc", "com.example.app/../../..", "..", ".", "...", "/etc/passwd", "~", "~/Documents"] { + XCTAssertNil( + UninstallerLeftovers.sanitizedIdentifier(hostile), + "identifier \(hostile) should not be usable as a path component" + ) + } + } + + func testTraversalIdentifierProducesNoCandidatesAtAll() { + // The display name is deliberately too short to trigger the fallback, so an empty result + // proves the traversal attempt produced nothing rather than being quietly replaced. + XCTAssertTrue(candidates(bundleIdentifier: "../../../etc", appName: "ab").isEmpty) + } + + func testSeparatorAndControlCharacterIdentifiersAreRefused() { + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com/example/app")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com:example:app")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com.example\u{0}app")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com.example\napp")) + } + + func testEmptyAndOverlongIdentifiersAreRefused() { + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier(nil)) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier(" ")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier(String(repeating: "a", count: 256))) + XCTAssertEqual(UninstallerLeftovers.sanitizedIdentifier(String(repeating: "a", count: 255))?.count, 255) + } + + // MARK: - Prefix collisions between apps + + func testCandidatesNeverReachAnIdentifierThatMerelySharesThePrefix() { + let paths = Set(candidates(bundleIdentifier: "com.example.app").map(\.url.path)) + + XCTAssertFalse(paths.contains { $0.contains("com.example.app.helper") }) + XCTAssertFalse(paths.contains { $0.contains("com.example.application") }) + } + + func testScanIgnoresNeighboursWhoseIdentifierExtendsTheOneBeingRemoved() throws { + try makeLeaf(userLibrary, "Application Support", "com.example.app") + try makeLeaf(userLibrary, "Application Support", "com.example.app.helper") + try makeLeaf(userLibrary, "Application Support", "com.example.application") + try makeLeaf(userLibrary, "Caches", "com.example.app.helper") + + let found = scan(bundleIdentifier: "com.example.app").map(\.url.path) + + XCTAssertEqual(found, [userLibrary.appendingPathComponent("Application Support/com.example.app").path]) + } + + func testScanFindsEveryLocationThatActuallyExists() throws { + try makeLeaf(userLibrary, "Application Support", "com.example.app", bytes: 100) + try makeLeaf(userLibrary, "Caches", "com.example.app", bytes: 100) + try makeLeaf(systemLibrary, "Logs", "com.example.app", bytes: 100) + + let found = scan(bundleIdentifier: "com.example.app") + + XCTAssertEqual(found.count, 3) + XCTAssertEqual(Set(found.map(\.candidate.label)), ["Application Support", "Caches", "Logs"]) + XCTAssertEqual(found.filter { $0.candidate.scope == .system }.count, 1) + XCTAssertTrue(found.allSatisfy { $0.bytes > 0 }, "an existing leftover should report a measured size") + } + + // MARK: - The display-name fallback + + func testNameFallbackOnlyTouchesTheDirectoriesThatAreEverNamedAfterAnApp() { + let derived = candidates(bundleIdentifier: nil, appName: "Fantastical") + + XCTAssertEqual(Set(derived.map(\.label)), ["Application Support", "Caches", "Logs"]) + XCTAssertEqual(derived.count, 6) + XCTAssertTrue(derived.allSatisfy { $0.match == .appName }) + } + + func testNameMatchedCandidatesAreNeverTickedByDefault() { + let items = candidates(bundleIdentifier: nil, appName: "Fantastical") + .map { LeftoverItem(candidate: $0, bytes: 10) } + + XCTAssertFalse(items.isEmpty) + XCTAssertTrue(UninstallerLeftovers.defaultSelection(for: items).isEmpty) + } + + func testNameFallbackRefusesUmbrellaVendorDirectories() { + for shared in ["Google", "Adobe", "Microsoft", "Mozilla", "Steam"] { + XCTAssertFalse( + UninstallerLeftovers.isNameSpecificEnough(shared), + "\(shared) names a directory shared by several apps" + ) + XCTAssertTrue(candidates(bundleIdentifier: nil, appName: shared).isEmpty) + } + } + + func testNameFallbackRefusesGenericDirectoryNames() { + for generic in ["Logs", "caches", "Support", "Preferences", "Data", "Updater"] { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough(generic)) + } + } + + func testNameFallbackRefusesNamesTooShortToBeUnambiguous() { + for short in ["Go", "Zip", "IDE", "A"] { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough(short)) + } + + XCTAssertTrue(UninstallerLeftovers.isNameSpecificEnough("Bear")) + } + + func testNameFallbackRefusesNamesAnotherInstalledAppAlsoAnswersTo() { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("Fantastical", otherAppNames: ["Fantastical"])) + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("Fantastical", otherAppNames: ["fantastical"])) + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("Café Noir", otherAppNames: ["Cafe Noir"])) + XCTAssertTrue(UninstallerLeftovers.isNameSpecificEnough("Fantastical", otherAppNames: ["Fantastical Helper"])) + } + + func testNameFallbackRefusesNamesThatWouldEscapeTheirDirectory() { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("../../Documents")) + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("/Users/someone")) + XCTAssertTrue(candidates(bundleIdentifier: nil, appName: "../../Documents").isEmpty) + } + + func testNameCandidatesAreAddedAlongsideBundleIdentifierCandidatesWithoutDuplicates() { + let derived = candidates(bundleIdentifier: "com.flexibits.fantastical2.mac", appName: "Fantastical") + + XCTAssertEqual(derived.filter { $0.match == .bundleIdentifier }.count, UninstallerLeftovers.locations.count * 2) + XCTAssertEqual(derived.filter { $0.match == .appName }.count, 6) + XCTAssertEqual(Set(derived.map(\.id)).count, derived.count) + } + + // MARK: - The removal fence + + func testFenceAcceptsADirectChildOfAKnownRoot() { + let url = userLibrary.appendingPathComponent("Caches/com.example.app") + + XCTAssertTrue(UninstallerLeftovers.isRemovable(url, allowedRoots: roots)) + } + + func testFenceRefusesTheRootItself() { + for root in roots { + XCTAssertFalse(UninstallerLeftovers.isRemovable(root, allowedRoots: roots)) + } + + XCTAssertFalse(UninstallerLeftovers.isRemovable(userLibrary, allowedRoots: roots)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(sandbox, allowedRoots: roots)) + } + + func testFenceRefusesAnythingDeeperThanADirectChild() { + let nested = userLibrary.appendingPathComponent("Caches/com.example.app/Data/file.db") + + XCTAssertFalse(UninstallerLeftovers.isRemovable(nested, allowedRoots: roots)) + } + + func testFenceRefusesASiblingDirectoryThatMerelySharesTheRootsNamePrefix() { + let lookalike = userLibrary.appendingPathComponent("CachesElsewhere/com.example.app") + + XCTAssertFalse(UninstallerLeftovers.isRemovable(lookalike, allowedRoots: roots)) + } + + func testFenceRefusesTraversalOutOfARoot() { + let escapes = [ + userLibrary.appendingPathComponent("Caches/../../../etc/passwd"), + userLibrary.appendingPathComponent("Caches/../Keychains/login.keychain-db"), + userLibrary.appendingPathComponent("Caches/.."), + URL(fileURLWithPath: "/etc/passwd"), + sandbox.appendingPathComponent("Documents/Taxes") + ] + + for escape in escapes { + XCTAssertFalse( + UninstallerLeftovers.isRemovable(escape, allowedRoots: roots), + "\(escape.path) is outside every allowed root" + ) + } + } + + func testFenceIgnoresRootsShallowEnoughToHoldWholeVolumesOrAccounts() { + let reckless = [URL(fileURLWithPath: "/"), URL(fileURLWithPath: "/Users"), URL(fileURLWithPath: "/Volumes")] + + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Users"), allowedRoots: reckless)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Applications"), allowedRoots: reckless)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Volumes/Backup"), allowedRoots: reckless)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Library/Caches/com.example.app"), allowedRoots: [])) + } + + // MARK: - Symlinks + + func testFenceRefusesASymlinkThatResolvesOutsideEveryAllowedRoot() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + + let planted = userLibrary.appendingPathComponent("Caches/com.example.app") + try FileManager.default.createSymbolicLink(at: planted, withDestinationURL: secrets) + + XCTAssertFalse(UninstallerLeftovers.isRemovable(planted, allowedRoots: roots)) + } + + func testFenceRefusesASymlinkAimedAtTheHomeDirectory() throws { + let planted = userLibrary.appendingPathComponent("Application Support/com.example.app") + try FileManager.default.createSymbolicLink( + at: planted, + withDestinationURL: FileManager.default.homeDirectoryForCurrentUser + ) + + XCTAssertFalse(UninstallerLeftovers.isRemovable(planted, allowedRoots: roots)) + } + + func testFenceStillAcceptsASymlinkThatStaysInsideAnAllowedRoot() throws { + let real = try makeLeaf(userLibrary, "Caches", "com.example.real") + let planted = userLibrary.appendingPathComponent("Caches/com.example.app") + try FileManager.default.createSymbolicLink(at: planted, withDestinationURL: real) + + XCTAssertTrue(UninstallerLeftovers.isRemovable(planted, allowedRoots: roots)) + } + + func testScanDropsAnEscapingSymlinkInsteadOfOfferingIt() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + try Data(repeating: 0x42, count: 4_096).write(to: secrets.appendingPathComponent("taxes.pdf")) + + try FileManager.default.createSymbolicLink( + at: userLibrary.appendingPathComponent("Caches/com.example.app"), + withDestinationURL: secrets + ) + try makeLeaf(userLibrary, "Logs", "com.example.app") + + let found = scan(bundleIdentifier: "com.example.app") + + XCTAssertEqual(found.map(\.candidate.label), ["Logs"]) + XCTAssertTrue(FileManager.default.fileExists(atPath: secrets.appendingPathComponent("taxes.pdf").path)) + } + + // MARK: - Removal refuses what the fence refuses + + func testMoveToTrashRefusesAnItemOutsideTheAllowedRootsAndLeavesItOnDisk() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + let victim = secrets.appendingPathComponent("taxes.pdf") + try Data(repeating: 0x43, count: 128).write(to: victim) + + // A hand-built item, i.e. exactly the shape a bug elsewhere in the tool would produce. + let smuggled = LeftoverItem( + candidate: LeftoverCandidate(url: victim, label: "Caches", scope: .user, match: .bundleIdentifier), + bytes: 128 + ) + + let result = LeftoverScanner.moveToTrash([smuggled], userLibrary: userLibrary, systemLibrary: systemLibrary) + + XCTAssertEqual(result.trashedCount, 0) + XCTAssertEqual(result.trashedBytes, 0) + XCTAssertEqual(result.failures.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path), "the fence must leave a refused path untouched") + } + + func testMoveToTrashRefusesAnEscapingSymlinkWithoutFollowingIt() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + let victim = secrets.appendingPathComponent("taxes.pdf") + try Data(repeating: 0x44, count: 128).write(to: victim) + + let planted = userLibrary.appendingPathComponent("Caches/com.example.app") + try FileManager.default.createSymbolicLink(at: planted, withDestinationURL: secrets) + + let smuggled = LeftoverItem( + candidate: LeftoverCandidate(url: planted, label: "Caches", scope: .user, match: .bundleIdentifier), + bytes: 128 + ) + + let result = LeftoverScanner.moveToTrash([smuggled], userLibrary: userLibrary, systemLibrary: systemLibrary) + + XCTAssertEqual(result.trashedCount, 0) + XCTAssertEqual(result.failures.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path)) + XCTAssertNotNil(try? FileManager.default.destinationOfSymbolicLink(atPath: planted.path)) + } + + func testMoveToTrashOnNothingIsANoOp() { + let result = LeftoverScanner.moveToTrash([], userLibrary: userLibrary, systemLibrary: systemLibrary) + + XCTAssertEqual(result, LeftoverRemovalResult()) + } + + // MARK: - Selection arithmetic + + func testSelectedBytesCountsOnlyTheTickedRows() { + let items = candidates(bundleIdentifier: "com.example.app") + .prefix(3) + .enumerated() + .map { LeftoverItem(candidate: $1, bytes: UInt64(($0 + 1) * 100)) } + + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: []), 0) + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: [items[0].id, items[2].id]), 400) + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: Set(items.map(\.id))), 600) + } + + func testSelectedBytesIgnoresIdentifiersThatAreNotInTheList() { + let items = candidates(bundleIdentifier: "com.example.app") + .prefix(1) + .map { LeftoverItem(candidate: $0, bytes: 500) } + + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: ["/some/other/path"]), 0) + } +} From 445c1e54d0611f3b92c3e5048430036025a91bc8 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 23:07:52 +0530 Subject: [PATCH 2/2] Uninstall Apps: keep telling the user what the sweep actually did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uninstall report was rendered inside the selected-app branch of the details pane, which meant it vanished in precisely the case it mattered most. Users search to find the app they want gone, so the app they trash is usually the only match; removing it empties the filtered list, leaves nothing selected, and switches the pane to the "No apps found" placeholder. The async task's status and failure writes then landed on a branch that was no longer on screen. Since this tool is not privileged and has no admin escalation, the /Library rows almost always fail — so the user was silently told nothing and walked away believing the machine was clean. The report is now a property of the pane rather than of the selected app. UninstallerDetailsPane.resolve decides the app, the placeholder and the feedback in one pass, and the pane draws the feedback in both branches, so an emptied list still gets to explain itself. The success line and the failure line were also chained with `else if`, which is wrong on the same grounds: a partial sweep is one outcome with two halves. Told only that two /Library items were refused, the user goes hunting in ~/Library for the three files that are already in the Trash. UninstallerFeedback holds both and the pane stacks them. Finally, tapping a different app in the sidebar now clears the report. It names the app it describes, so leaving "Moved Slack and 3 support items to Trash." on screen under Zoom's name, path and size reads as a claim that Zoom was just trashed. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/Uninstaller.swift | 184 ++++++++++++++---- .../UninstallerSelectionTests.swift | 143 ++++++++++++++ 2 files changed, 293 insertions(+), 34 deletions(-) diff --git a/Sources/DMonteCore/Uninstaller.swift b/Sources/DMonteCore/Uninstaller.swift index 923c5de..2c3cfeb 100644 --- a/Sources/DMonteCore/Uninstaller.swift +++ b/Sources/DMonteCore/Uninstaller.swift @@ -118,6 +118,97 @@ enum UninstallerSelection { } } +/// What the last uninstall has to say for itself. +/// +/// The success line and the failure line are two halves of one outcome rather than alternatives. +/// A sweep that trashes three items out of `~/Library` and is refused on two out of `/Library` has +/// to report both: told only about the failure, the user goes hunting in `~/Library` for files +/// that are already in the Trash, or re-runs the sweep. +struct UninstallerFeedback: Equatable, Sendable { + /// What did move. Rendered in the secondary colour. + var status: String? + + /// What did not. Rendered in red, *underneath* the status line rather than instead of it. + var failure: String? + + static let empty = UninstallerFeedback() + + var isEmpty: Bool { + status == nil && failure == nil + } + + /// macOS refused the bundle itself, so the sweep never ran and nothing moved. + static func bundleRefused(reason: String) -> UninstallerFeedback { + UninstallerFeedback(failure: reason) + } + + /// The bundle moved and there was nothing else to do — either no leftovers were found or the + /// user unticked every row in the review sheet. + static func bundleTrashed(appName: String) -> UninstallerFeedback { + UninstallerFeedback(status: "Moved \(appName) to Trash.") + } + + /// The bundle moved and the sweep ran. Both lines are filled in when the sweep was partial, + /// which is the common case: the tool is not privileged, so the `/Library` rows usually fail + /// while the `~/Library` ones succeed. + static func sweepFinished(appName: String, result: LeftoverRemovalResult) -> UninstallerFeedback { + var feedback = UninstallerFeedback() + + // "and 0 support items (Zero KB)" is technically true and reads like a malfunction, so a + // sweep that moved nothing reports the bundle alone and lets the failure line explain why. + if result.trashedCount == 0 { + feedback.status = "Moved \(appName) to Trash." + } else { + let noun = result.trashedCount == 1 ? "item" : "items" + feedback.status = "Moved \(appName) and \(result.trashedCount) support \(noun) (\(result.trashedBytes.bytesString)) to Trash." + } + + if let firstFailure = result.failures.first { + feedback.failure = result.failures.count == 1 + ? "Could not remove \(firstFailure.path): \(firstFailure.reason)" + : "Could not remove \(result.failures.count) support items. First: \(firstFailure.path) — \(firstFailure.reason)" + } + + return feedback + } +} + +/// Everything the details pane draws, resolved in one pass. +/// +/// The feedback is a field of the pane rather than something the selected-app branch renders, and +/// that is the entire point of the type: uninstalling the last app matching the current search +/// leaves nothing selected, and that is precisely the case where the user most needs to hear that +/// half the sweep was refused. Nested inside the selected-app branch, that report was silently +/// dropped exactly when it mattered. +struct UninstallerDetailsPane: Equatable, Sendable { + /// The app whose details are shown, or nil when the list has nothing to show. + let app: InstalledApplication? + + /// The stand-in shown when `app` is nil. Non-nil exactly when `app` is nil. + let placeholder: String? + + /// The outcome of the last uninstall, shown whether or not an app is selected. + let feedback: UninstallerFeedback + + var showsFeedback: Bool { + !feedback.isEmpty + } + + static func resolve( + filteredApps: [InstalledApplication], + selectedID: InstalledApplication.ID?, + feedback: UninstallerFeedback + ) -> UninstallerDetailsPane { + let app = UninstallerSelection.selectedApp(in: filteredApps, selectedID: selectedID) + + return UninstallerDetailsPane( + app: app, + placeholder: app == nil ? (filteredApps.isEmpty ? "No apps found" : "Select an app") : nil, + feedback: feedback + ) + } +} + public struct UninstallerPopoverView: View { var onQuit: () -> Void @@ -127,8 +218,7 @@ public struct UninstallerPopoverView: View { @State private var isScanning = true @State private var isSizing = false @State private var scanToken = UUID() - @State private var errorMessage: String? - @State private var statusMessage: String? + @State private var feedback = UninstallerFeedback.empty @State private var review: LeftoverReview? @State private var selectedLeftoverIDs: Set = [] @State private var isPreparingRemoval = false @@ -242,7 +332,7 @@ public struct UninstallerPopoverView: View { LazyVStack(spacing: 4) { ForEach(filteredApps) { app in AppRow(app: app, isSelected: app.id == selectedApp?.id) { - selectedAppID = app.id + select(app) } } } @@ -254,16 +344,26 @@ public struct UninstallerPopoverView: View { } private var details: some View { - VStack(alignment: .leading, spacing: 14) { - if let selectedApp { + // Resolved in one pass, up front, because the uninstall report belongs to the pane and not + // to the selected app: trashing the last app that matches the current search leaves nothing + // selected, and that is exactly the moment the user most needs to hear that the /Library + // half of the sweep was refused. + let pane = UninstallerDetailsPane.resolve( + filteredApps: filteredApps, + selectedID: selectedAppID, + feedback: feedback + ) + + return VStack(alignment: .leading, spacing: 14) { + if let app = pane.app { HStack(alignment: .top, spacing: 14) { - AppIcon(url: selectedApp.url, size: 64) + AppIcon(url: app.url, size: 64) VStack(alignment: .leading, spacing: 4) { - Text(selectedApp.name) + Text(app.name) .font(.system(size: 22, weight: .bold)) - Text(selectedApp.path) + Text(app.path) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(2) @@ -272,7 +372,7 @@ public struct UninstallerPopoverView: View { Spacer() - Text(selectedApp.sizeLabel) + Text(app.sizeLabel) .font(.system(size: 14, weight: .bold, design: .rounded)) .foregroundStyle(.secondary) } @@ -283,21 +383,13 @@ public struct UninstallerPopoverView: View { .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) - if let errorMessage { - Text(errorMessage) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.red) - } else if let statusMessage { - Text(statusMessage) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - } + feedbackLines(pane.feedback, alignment: .leading) Spacer() HStack { Button { - NSWorkspace.shared.activateFileViewerSelecting([selectedApp.url]) + NSWorkspace.shared.activateFileViewerSelecting([app.url]) } label: { Label("Reveal", systemImage: "finder") } @@ -310,7 +402,7 @@ public struct UninstallerPopoverView: View { } Button(role: .destructive) { - beginUninstall(selectedApp) + beginUninstall(app) } label: { Label("Move to Trash", systemImage: "trash") } @@ -324,9 +416,11 @@ public struct UninstallerPopoverView: View { .font(.system(size: 34, weight: .semibold)) .foregroundStyle(.secondary) - Text(filteredApps.isEmpty ? "No apps found" : "Select an app") + Text(pane.placeholder ?? "") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.secondary) + + feedbackLines(pane.feedback, alignment: .center) } .frame(maxWidth: .infinity) Spacer() @@ -335,6 +429,35 @@ public struct UninstallerPopoverView: View { .padding(22) } + /// Both halves of the last uninstall's outcome, stacked rather than chosen between. They are + /// not alternatives: a sweep that moved three items out of `~/Library` and was refused on two + /// out of `/Library` has to report both, or the user goes hunting in `~/Library` for files + /// that are already in the Trash. + private func feedbackLines(_ feedback: UninstallerFeedback, alignment: HorizontalAlignment) -> some View { + VStack(alignment: alignment, spacing: 4) { + if let status = feedback.status { + Text(status) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + + if let failure = feedback.failure { + Text(failure) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.red) + } + } + .multilineTextAlignment(alignment == .center ? .center : .leading) + } + + /// Moves the selection, and drops the previous uninstall's report along with it. The report + /// names the app it describes, so leaving it on screen under a different app's name, path and + /// size reads as a claim that *this* app was just trashed. + private func select(_ app: InstalledApplication) { + selectedAppID = app.id + feedback = .empty + } + private var filteredApps: [InstalledApplication] { UninstallerSelection.filteredApps(apps, query: searchText) } @@ -348,7 +471,7 @@ public struct UninstallerPopoverView: View { scanToken = token isScanning = true isSizing = false - errorMessage = nil + feedback = .empty let scannedApps = await Task.detached(priority: .userInitiated) { ApplicationScanner.scan() }.value @@ -427,8 +550,7 @@ public struct UninstallerPopoverView: View { } } - errorMessage = nil - statusMessage = nil + feedback = .empty isPreparingRemoval = true // The names of the *other* installed apps are what lets the display-name fallback refuse @@ -466,16 +588,16 @@ public struct UninstallerPopoverView: View { var resultingURL: NSURL? try FileManager.default.trashItem(at: app.url, resultingItemURL: &resultingURL) } catch { - errorMessage = error.localizedDescription + feedback = .bundleRefused(reason: error.localizedDescription) return } apps.removeAll { $0.id == app.id } selectedAppID = filteredApps.first?.id - errorMessage = nil + feedback = .empty guard !chosen.isEmpty else { - statusMessage = "Moved \(app.name) to Trash." + feedback = .bundleTrashed(appName: app.name) return } @@ -487,13 +609,7 @@ public struct UninstallerPopoverView: View { }.value isRemoving = false - statusMessage = "Moved \(app.name) and \(result.trashedCount) support \(result.trashedCount == 1 ? "item" : "items") (\(result.trashedBytes.bytesString)) to Trash." - - if let firstFailure = result.failures.first { - errorMessage = result.failures.count == 1 - ? "Could not remove \(firstFailure.path): \(firstFailure.reason)" - : "Could not remove \(result.failures.count) support items. First: \(firstFailure.path) — \(firstFailure.reason)" - } + feedback = .sweepFinished(appName: app.name, result: result) } } } diff --git a/Tests/DMonteCoreTests/UninstallerSelectionTests.swift b/Tests/DMonteCoreTests/UninstallerSelectionTests.swift index 4e294e7..c8a32a7 100644 --- a/Tests/DMonteCoreTests/UninstallerSelectionTests.swift +++ b/Tests/DMonteCoreTests/UninstallerSelectionTests.swift @@ -37,4 +37,147 @@ final class UninstallerSelectionTests: XCTestCase { XCTAssertNil(selected) } + + // MARK: - The details pane + + /// The regression this file exists for. Uninstalling the last app that matches the current + /// search is the *common* case — users search to find the app they want gone — and it is also + /// the case where the leftover sweep has most likely been half-refused, because the tool is + /// not privileged and cannot touch `/Library`. Rendering the report inside the selected-app + /// branch threw it away exactly then, and the user walked away believing the machine was clean. + func testDetailsPaneStillReportsTheSweepWhenTheUninstallEmptiedTheList() { + var apps = [alpha, beta] + let searchText = "Alpha" + + // What `performUninstall` does after macOS accepts the bundle. + apps.removeAll { $0.id == alpha.id } + let filtered = UninstallerSelection.filteredApps(apps, query: searchText) + let selectedID = filtered.first?.id + let feedback = UninstallerFeedback.sweepFinished( + appName: alpha.name, + result: LeftoverRemovalResult( + trashedCount: 3, + trashedBytes: 412_000_000, + failures: [ + LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied") + ] + ) + ) + + XCTAssertTrue(filtered.isEmpty) + XCTAssertNil(selectedID) + + let pane = UninstallerDetailsPane.resolve( + filteredApps: filtered, + selectedID: selectedID, + feedback: feedback + ) + + XCTAssertNil(pane.app) + XCTAssertEqual(pane.placeholder, "No apps found") + XCTAssertTrue(pane.showsFeedback) + XCTAssertEqual(pane.feedback, feedback) + } + + func testDetailsPaneCarriesTheFeedbackAlongsideASelectedApp() { + let filtered = UninstallerSelection.filteredApps([alpha, beta], query: "") + let feedback = UninstallerFeedback.bundleTrashed(appName: "Gamma") + + let pane = UninstallerDetailsPane.resolve( + filteredApps: filtered, + selectedID: beta.id, + feedback: feedback + ) + + XCTAssertEqual(pane.app, beta) + XCTAssertNil(pane.placeholder) + XCTAssertEqual(pane.feedback, feedback) + } + + func testDetailsPaneShowsNoFeedbackWhenNothingHasHappenedYet() { + let pane = UninstallerDetailsPane.resolve( + filteredApps: [], + selectedID: nil, + feedback: .empty + ) + + XCTAssertNil(pane.app) + XCTAssertFalse(pane.showsFeedback) + } + + // MARK: - Uninstall feedback + + /// A partial sweep has to say both halves. Reporting only the failure sends the user hunting + /// in `~/Library` for files that are already in the Trash, or re-running the sweep. + func testSweepFeedbackReportsWhatMovedAndWhatDidNot() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult( + trashedCount: 3, + trashedBytes: 412_000_000, + failures: [ + LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied"), + LeftoverRemovalFailure(path: "/Library/Logs/com.example.alpha", reason: "Permission denied") + ] + ) + ) + + XCTAssertNotNil(feedback.status) + XCTAssertTrue(feedback.status?.contains("Alpha") == true) + XCTAssertTrue(feedback.status?.contains("3 support items") == true) + + XCTAssertTrue(feedback.failure?.contains("2 support items") == true) + XCTAssertTrue(feedback.failure?.contains("/Library/Caches/com.example.alpha") == true) + } + + func testSweepFeedbackNamesTheOnlyFailureOutright() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult( + trashedCount: 1, + trashedBytes: 1024, + failures: [LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied")] + ) + ) + + XCTAssertTrue(feedback.status?.contains("1 support item ") == true) + XCTAssertEqual(feedback.failure, "Could not remove /Library/Caches/com.example.alpha: Permission denied") + } + + func testSweepFeedbackDropsTheItemCountWhenEverySweepFailed() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult( + trashedCount: 0, + trashedBytes: 0, + failures: [LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied")] + ) + ) + + XCTAssertEqual(feedback.status, "Moved Alpha to Trash.") + XCTAssertNotNil(feedback.failure) + } + + func testSweepFeedbackWithNoFailuresHasNothingRedToSay() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult(trashedCount: 2, trashedBytes: 2048) + ) + + XCTAssertNotNil(feedback.status) + XCTAssertNil(feedback.failure) + } + + func testRefusedBundleReportsOnlyTheFailure() { + let feedback = UninstallerFeedback.bundleRefused(reason: "Operation not permitted") + + XCTAssertNil(feedback.status) + XCTAssertEqual(feedback.failure, "Operation not permitted") + XCTAssertFalse(feedback.isEmpty) + } + + func testEmptyFeedbackIsEmpty() { + XCTAssertTrue(UninstallerFeedback.empty.isEmpty) + XCTAssertFalse(UninstallerFeedback.bundleTrashed(appName: "Alpha").isEmpty) + } }