Uninstall Apps: actually remove the support files an app leaves behind - #23
Open
havokentity wants to merge 2 commits into
Open
Uninstall Apps: actually remove the support files an app leaves behind#23havokentity wants to merge 2 commits into
havokentity wants to merge 2 commits into
Conversation
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 `<known root>/<identifier><suffix>`, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave 2 Phase A.
Implementation: Uninstall Apps: actually remove the support files an app leaves behind
Review fixes: Uninstall Apps: keep telling the user what the sweep actually did
Built by an isolated agent, then adversarially reviewed by an independent agent that checked the branch out and ran
swift build+ the full suite itself. Every review finding was fixed, with a regression test added for each high and medium.🤖 Generated with Claude Code