skip to content
the husk app icon next to the husk wordmark on a dark background, with badges reading quarantine not delete, confidence scored, and gui plus cli one engine
project

husk: a macOS uninstaller that hunts down every leftover

a native macOS app uninstaller: confidence-scored leftover detection across containers, caches, preferences, and launch agents, sharing one engine between a SwiftUI GUI and a CLI

· 7 min read

Dragging an app to the Trash removes the bundle and leaves everything else behind: caches, preferences, containers, launch agents, Spotlight registrations. Most uninstallers “fix” this by fuzzy-matching folder names and deleting anything that looks related, which is exactly how they end up deleting the wrong thing. husk is built around a stricter idea: nothing gets removed without a stated reason, and nothing gets deleted outright. It goes to quarantine first, restorable, until you say otherwise.

It ships as a SwiftUI app and a husk CLI. Both are a thin shell over the same UninstallKit library, so the GUI isn’t a second implementation trailing behind the CLI: same scan, same confidence tiers, same safety rules, either surface.

key features

  • Confidence-scored matching: exact (bundle ID or executable found in the file), likely (team ID or exact folder-name match), possible (fuzzy resemblance, opt-in only, never pre-selected)
  • Scans containers, caches, preferences (including ByHost), Group Containers, Application Scripts, Saved Application State, launch agents/daemons, and installer receipts via pkgutil
  • Reference-counts shared components (Microsoft AutoUpdate, shared containers) so removing one app never breaks another
  • A dedicated Ghosts view for registrations that outlive the apps that made them: stale Launch Services entries, orphaned Full Disk Access grants, dead Spotlight importers
  • Nothing is deleted directly: items move to quarantine via FileManager.trashItem (or a user-owned quarantine folder for root-owned files), restorable until purged
  • Reduced-scan mode when Full Disk Access isn’t granted: husk still runs and says plainly what it couldn’t see, instead of quietly reporting a clean system
  • Same capability set in the CLI as the GUI, including --dry-run, --json, and --include-shared

tech stack

# core
swift 5.9 | swiftui | swift package manager
# cli
swift-argument-parser
# system integration
pkgutil | lsregister | pluginkit | fileproviderctl | tccutil | cfprefsd

how it works

Engine first, surfaces second. Package.swift builds three targets off one library: UninstallKit does the scanning, matching, and removal logic; HuskCLI and HuskApp are both thin dependents of it. Any behavior fixed in the engine is fixed everywhere at once.

Matching is a confidence ladder, not a boolean. A bundle identifier found inside a file, or an installer receipt listing it directly, is exact. A team identifier or a folder name matching the app name exactly is likely and is pre-checked by default. Anything weaker (fuzzy name resemblance) is possible: it requires an explicit opt-in and is never pre-selected. Names under five characters and a denylist of generic words (mail, notes, code, sync) are excluded from fuzzy matching entirely, since that’s where most uninstallers cause real damage.

Removal order is load-bearing. TCC privacy grants are reset and the app is unregistered from Launch Services before the bundle is deleted, because once the bundle is gone, tccutil and lsregister can no longer resolve its identifier to reset it. cfprefsd is killed after any preference removal, otherwise the daemon writes its cached copy straight back to disk. These two orderings are covered by their own acceptance tests, since getting them backwards silently ships a tool that looks like it works.

Ghosts are handled honestly, not optimistically. A stale pluginkit registration or Launch Services entry gets cleared directly. An orphaned Spotlight entry is different: clearing it fully requires a reindex that can take hours, so husk states that plainly and offers it as a separate, explicit opt-in rather than folding it into Fix all. A TCC grant whose bundle ID no longer resolves can only be cleared by resetting the entire privacy category, which would revoke access for every other app using it. husk shows the entry, names exactly what else would lose access, and refuses to bundle that into a one-click fix.

Quarantine is always user-owned. Everything moved to quarantine, including root-owned items relocated to ~/.husk-quarantine/ via an authorized helper, ends up chown’d to the invoking user, so the quarantine directory can be deleted without sudo. A manifest.json per run records the original path, size, and ownership of every item, which is what makes husk restore possible.

cli usage

$ husk scan AppCleaner
  100%  done
AppCleaner 3.6.8
  net.freemacsoft.AppCleaner  team X85ZX835W9
  /Applications/AppCleaner.app

  exact     8.4 MB     /Applications/AppCleaner.app [admin]
             Application: The application bundle
  exact     66 KB      ~/Library/HTTPStorages/net.freemacsoft.AppCleaner
             Web Data: Named for the bundle identifier net.freemacsoft.AppCleaner
  exact     4 KB       ~/Library/Preferences/net.freemacsoft.AppCleaner.plist
             Preferences: Named for the bundle identifier net.freemacsoft.AppCleaner

  3 item(s), 8.5 MB
$ husk ghosts
Stale registrations

  Launch Services
    com.google.GoogleUpdater
      missing: ~/Library/Application Support/Google/GoogleUpdater/148.0.7730.0/GoogleUpdater.app
      Registered bundle no longer exists on disk
    com.microsoft.OneDrive
      missing: ~/.husk-quarantine/2026-08-24T07-17-34Z/Applications/OneDrive.app
      Registered bundle no longer exists on disk

  2 stale registration(s)

  Repair
    /System/Library/.../LaunchServices.framework/Support/lsregister -delete
    /usr/bin/killall lsd Dock Finder

  Rebuilds the Launch Services database and restarts Finder and the Dock,
  which sometimes clears extension list entries too, but not reliably.
  Ghost login items live in a separate database `sudo sfltool resetbtm`
  clears it, but wipes every background item for every app, so treat that
  as a last resort.

  Run `husk ghosts --repair` to do this for you.

That second block is the real output, unedited, including the caveat about sfltool resetbtm being a last resort. husk telling you a fix is incomplete, rather than reporting a clean sweep, is the same design principle as the GUI’s reduced-scan banner: say what’s actually true, even when the honest answer is “not fully.”

highlights

App list, scan results with confidence tiers, and the Ghosts view for stale registrations.

The ordering constraint around TCC and Launch Services was the part I underestimated going in. It reads like an implementation detail — reset privacy grants before you delete the bundle — but it’s actually the whole reason a lot of uninstallers leave orphaned Full Disk Access entries behind: once the bundle is gone, the identifier that tccutil needs to resolve is gone with it, and there’s no clean way back in. Writing the acceptance criteria before the removal sequence forced that ordering to be explicit instead of something I’d have gotten right by accident once and broken later during a refactor.

The confidence tiers turned out to matter more than I expected, too. It’s tempting to build an uninstaller around “does the name look similar,” but that’s precisely the heuristic that deletes ~/Library/Caches/Notes because someone had an app called notes-sync. Splitting matches into exact, likely, and possible (with possible requiring an explicit opt-in and a denylist for generic short names) pushed most of the design work into deciding what not to trust automatically, which ended up being more interesting than the removal code itself.