Skip to content

Uninstall Apps: actually remove the support files an app leaves behind - #23

Open
havokentity wants to merge 2 commits into
mainfrom
feat/uninstaller-leftovers
Open

Uninstall Apps: actually remove the support files an app leaves behind#23
havokentity wants to merge 2 commits into
mainfrom
feat/uninstaller-leftovers

Conversation

@havokentity

Copy link
Copy Markdown
Owner

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

havokentity and others added 2 commits July 21, 2026 13:02
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant