Uninstalling a Mac app is an attribution problem
Dragging an app to the Trash removes one folder. The app has usually been writing elsewhere for months: caches, preferences, containers, saved window state, logs, a launch agent or two. Finding those files is easy. Deciding which of them actually belong to the app you are removing is the whole problem, and it is where uninstallers go wrong.
ScrubJay is a small macOS app and CLI I wrote to do this one job. It is open source under Apache 2.0. This post is about the part that took the most thought: attribution.
Names lie a little
Most leftovers are keyed by bundle identifier. com.google.Chrome.plist is Chrome’s, no question. Then the edges start:
com.google.Chrome.beta.pliststarts with Chrome’s bundle ID but belongs to Chrome Beta, a different app.com.foo.App.Somethingcould be App’s helper process, or a sibling product from the same vendor that was uninstalled years ago.- Chrome’s main data lives in
Application Support/Google/Chrome, one level below a vendor folder that Chrome shares with every other Google app. - A group container is prefixed by a team ID,
5A4RE8SF68.com.tencent.xinWeChat, and may be shared by the vendor’s other apps.
A prefix match gets all of these wrong in the direction that deletes someone else’s data. So ScrubJay does not return a list of files. It returns files with a confidence level, and the level decides what the interface is allowed to preselect.
| Level | Meaning | Example | Preselected |
|---|---|---|---|
| certain | Bundle ID exact, or bundle ID plus a well-known suffix | com.google.Chrome.plist | yes |
| high | Bundle ID prefix plus a recognized helper token | com.google.Chrome.helper | yes |
| medium | Exact normalized app-name match | Google Chrome/ | yes, flagged |
| low | Weak signal: unknown suffix, channel token, shared container, very short name | com.google.Chrome.beta | never |
The rules are pure functions over file names. They never touch the filesystem, so every one of them has a unit test, and adding a new defense means adding a test case first.
The defenses, one hazard each
Each of these came from a real machine.
Channel variants. If a longer installed bundle ID claims an entry, it goes to that app and is excluded from the target’s results. If the sibling is not installed, a channel token (beta, canary, dev, nightly) right after the bundle ID caps the entry at low. It shows up in the report; it is never selected for you.
Unknown suffixes. Only recognized helper tokens (helper, renderer, updater, app, web, and a few more) earn high. Anything unrecognized stays low.
Vendor folders. The scanner descends exactly one level, only into a directory named after the app’s own vendor, and matches children by composing vendor and child against the app name: “Google” plus “Chrome” is “Google Chrome”. The vendor folder itself is never a result, and a child that composes another installed app’s name is excluded.
Ambiguous names. A name-keyed entry that also matches another installed app is attributed to neither.
Launch agents. Unloading one is a behavior change beyond moving a file, so it requires the plist’s own Label to carry the target’s bundle ID. A matching file name is not enough.
Every defense is biased the same way: missing a file is acceptable, deleting the wrong one is not.
Only the Trash
There is no permanent-delete code in the project. In the user’s domain, removal calls FileManager.trashItem and nothing else, and it refuses the filesystem root, the home directory, ~/Library, Documents, Desktop, Downloads and the top-level system folders even if the matching logic were wrong. Everything ScrubJay removes can be dragged back.
System-domain leftovers under /Library need root, which means a privileged helper, which is the riskiest code in any uninstaller. ScrubJay’s helper holds to four rules: the destination Trash and resulting ownership come from the XPC connection’s audit token, not from the request; files are moved by descriptor with renameat, never by re-resolving a path; sources must be direct children of the scanner’s own roots, checked by parent equality rather than prefix, so /Library/Keychains is unreachable by construction; and the helper moves, it never deletes. A test fails the build if the helper’s allow-list and the scanner’s roots ever diverge.
Trying it
brew install --cask openwhale-labs/tap/scrubjay
or, from a checkout, swift run scrubjay scan "Google Chrome":
Google Chrome (com.google.Chrome) — /Applications/Google Chrome.app
[certain]
~/Library/Preferences/com.google.Chrome.plist (4 KB)
[medium]
~/Library/Application Support/Google/Chrome (7.56 GB)
~/Library/Caches/Google/Chrome (1.78 GB)
3 items, 9.33 GB
scan never deletes. remove shows the same report, asks, and moves the selected items to the Trash. The engine, the CLI and the app are all in the repository; the confidence model and the helper rules are written up in docs/ARCHITECTURE.md.