Finds and removes what a macOS app leaves behind.
UninstallKit is the engine. husk is the CLI. The SwiftUI app links the same
library, so the GUI is a shell over this code rather than a second
implementation.
swift build -c release
.build/release/husk list
.build/release/husk scan "Some App"
.build/release/husk remove "Some App" --dry-run
.build/release/husk orphans --min-size-mb 50Confidence is the core concept. Nothing is deleted on a guess.
| Level | Signal | Example |
|---|---|---|
exact |
Bundle identifier match, executable inside the bundle, or a file listed in an installer receipt | ~/Library/Preferences/com.acme.app.plist |
likely |
Team identifier, vendor prefix, or the folder name equals the app name exactly | ~/Library/Group Containers/AB12CD34.com.acme |
possible |
Fuzzy name resemblance | ~/Library/Caches/Acme Helper |
likely is the default floor for removal. possible requires
--include-possible and should never be pre-selected in the GUI.
Names shorter than five characters and a denylist of generic words
(mail, notes, code, sync) are excluded from fuzzy matching entirely,
which is where most uninstallers cause damage.
Per-app
- The bundle itself
- Application Support, Caches, Preferences (including ByHost), Containers, Group Containers, Application Scripts, Saved Application State, Logs, Cookies, HTTPStorages, WebKit, Autosave Information
- Plug-in locations in both domains: Internet Plug-Ins, PreferencePanes, QuickLook, Services, Spotlight, Screen Savers, Audio units, Mail bundles
- launchd agents and daemons, matched by label and by program path, plus
/Library/PrivilegedHelperTools - Installer receipts via
pkgutil --pkg-infoandpkgutil --files, which give an authoritative file manifest for anything installed from a.pkg
System-wide
- Orphan scan: bundle-identifier-shaped Library entries with no installed app claiming them
- Nothing is deleted. User-owned items go to the Trash via
FileManager.trashItem, so Finder's Put Back still works. Root-owned items are moved to~/.husk-quarantine/<timestamp>/, sincetrashItemcannot move files the user does not own. Safety.isProtectedis checked immediately before every removal, not just at scan time. It rejects Library roots, anything under/System,/usr,/bin,com.apple.*names, Keychains, CloudDocs, TCC, and any path fewer than four components deep.- launchd jobs are booted out before their plists are deleted, otherwise the job stays loaded until reboot and can respawn its helper.
- The app is asked to quit, then force-terminated, before anything is touched. A running app rewrites its preferences on exit.
- Admin operations are batched into a single authorization prompt rather than one per file.
pkgutil --forgetis printed as a command rather than executed, because it is not reversible.
This cannot be sandboxed. Reading other apps' containers is precisely what the sandbox exists to prevent, so the App Store is not an option. Ship it Developer ID signed and notarized, distributed yourself.
The GUI will need to walk the user through granting Full Disk Access in System Settings on first launch. Without it, several Library subpaths silently return empty directory listings rather than an error, which looks like a clean system rather than a permissions failure. Detect this explicitly and say so.
- Privileged removal currently shells out to
osascript ... with administrator privileges. It works and prompts once, but the right answer is anSMAppServicedaemon with an XPC interface. Replace before shipping. - System extensions and network extensions are not handled yet.
systemextensionsctl listis the starting point. - Login items registered through
SMAppServiceare not enumerated. - Not compiled or run against a real system yet. Expect a first-pass compile round.