Skip to content

Add App Uninstaller extension - #31245

Open
quentinved wants to merge 4 commits into
raycast:mainfrom
quentinved:ext/app-uninstaller
Open

quentinved wants to merge 4 commits into
raycast:mainfrom
quentinved:ext/app-uninstaller

Conversation

@quentinved

Copy link
Copy Markdown
Contributor

Description

Uninstalls a macOS application and the files it leaves behind — caches, sandbox containers, preferences, launch agents, privileged helpers — after showing exactly what was found and why.

Dragging an app to the Trash leaves its data on disk. The usual fix is to delete anything whose name resembles the app, which is how people lose unrelated data. This extension is built the other way round: it explains every match, treats weak evidence as weak, and moves things to the Trash rather than deleting them.

How matching works

Confidence Evidence Pre-selected
Certain Named after the bundle identifier — com.bitwarden.desktop, a sub-component, or the Team-ID group container Yes
Likely Named exactly after the application Yes
Unsure Same developer prefix, or the name merely appears in the file name No

Four rules keep the weak cases weak:

  • Names too generic to identify anything (Helper, Updater, Code) are never matched on — only the bundle identifier counts for those apps.
  • Every candidate is tested against all installed apps. A file another app claims more strongly is dropped; one it claims equally is marked "Also matches " and left unselected.
  • Folders belonging to another app are not searched — Slack/VideoDecodeStats is Slack's file, however much it resembles the Stats app.
  • Two levels down, a resemblance-only match is discarded.

Safety

The design assumption is that the matcher will eventually be wrong about something.

  • Nothing is deleted — everything goes to the Trash.
  • Removal is gated by an allow-list of roots with per-root depth limits, re-checked immediately before acting.
  • Symlinks cannot escape: each path's parent is resolved before the check.
  • Shared vendor directories are protected — Application Support/Google is never removable, only one app's folder inside it.
  • Privileges are escalated only on an explicit, separate action for root-owned items (every App Store app), through the system's own password dialog. No path is interpolated into that command: paths arrive as argv and are quoted by AppleScript's quoted form of.
  • No shell strings anywhere else — all external calls use execFile with an argument list.
  • No network access and no telemetry.

npm test covers the path guard and the matcher, including a live symlink-escape attempt and assertions that unsafe paths are rejected before anything can escalate.

It also detects when something else is the better tool — a Homebrew cask, or a vendor-supplied uninstaller — and says so instead of proceeding.

Screencast

app-uninstaller-1

app-uninstaller-2

Checklist

@raycastbot raycastbot added new extension Label for PRs with new extensions platform: macOS labels Sep 18, 2026
@raycastbot

Copy link
Copy Markdown
Collaborator

Congratulations on your new Raycast extension! 🚀

We're currently experiencing a high volume of incoming requests. As a result, the initial review may take up to 15 business days.

Once the PR is approved and merged, the extension will be available on our Store.

Uninstalls a macOS application together with the files it leaves behind,
after showing what was found and why.
@greptile-apps

greptile-apps Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR is not safe to merge until the open custom Trash finding is fixed.

Findings

  1. P1 /bin/mv -f uses a fixed Trash/ destination. If the Trash already has a same-named file, -f can overwrite that older copy instead of choosing a unique name. A multi-file move can also move early items before a later item fails. Use Raycast's built-in trash method instead of custom Trash commands, as the repository rule requires. ▶
  2. P1 @raycast/api is declared and locked at 2.4.1. A rollback three days ago found that this API version made a Store listing uninstallable because no released Raycast build supported it; 2.2.1 restored installs. This command uses no feature shown to need 2.4.1. Pin the manifest and lockfile to the released API. ▶
Fix with agent prompt
### Issue 1
extensions/app-uninstaller/src/lib/elevate.ts:undefined-28
`/bin/mv -f` uses a fixed `Trash/<basename>` destination. If the Trash already has a same-named file, `-f` can overwrite that older copy instead of choosing a unique name. A multi-file move can also move early items before a later item fails. Use Raycast's built-in `trash()` method instead of custom Trash commands, as the repository rule requires.

### Issue 2
extensions/app-uninstaller/package.json:undefined-33
`@raycast/api` is declared and locked at 2.4.1. A rollback three days ago found that this API version made a Store listing uninstallable because no released Raycast build supported it; 2.2.1 restored installs. This command uses no feature shown to need 2.4.1. Pin the manifest and lockfile to the released API.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a macOS App Uninstaller command that shows an app’s leftovers, explains every match, and moves approved items to the Trash. It checks permissions, avoids weak or contested matches, and points users to Homebrew or vendor uninstallers when those are a better fit.

  • Lists installed apps by name, size, or last-used date.
  • Scans common macOS app-data locations and labels matches as Certain, Likely, or Unsure.
  • Separates protected items, administrator-only items, and failed removals with clear next steps.
  • Adds path-safety, matching, permission, and process-detection tests.

Reviews (4) · Last reviewed commit: "Stop a future timestamp taking down the ..."

Comment thread extensions/app-uninstaller/src/components/ReviewUninstall.tsx
const MOVE_TO_TRASH_AS_ADMIN = [
"on run argv",
"set trashPath to POSIX path of (path to trash folder)",
'set cmd to "/bin/mv -f --"',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 /bin/mv -f uses a fixed Trash/<basename> destination. If the Trash already has a same-named file, -f can overwrite that older copy instead of choosing a unique name. A multi-file move can also move early items before a later item fails. Use Raycast's built-in trash() method instead of custom Trash commands, as the repository rule requires.

Rule Used: What: Use Raycast's built-in trash() method instead of implementing custom trash functionality with system commands. Why: Raycast's trash() method is safer, cross-platform compatible, and integrates properly with the Raycast environment. Good: ... (source)

Knowledge Base Used: Extension command implementation patterns

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/app-uninstaller/src/lib/elevate.ts
Line: 20

Comment:
`/bin/mv -f` uses a fixed `Trash/<basename>` destination. If the Trash already has a same-named file, `-f` can overwrite that older copy instead of choosing a unique name. A multi-file move can also move early items before a later item fails. Use Raycast's built-in `trash()` method instead of custom Trash commands, as the repository rule requires.

**Rule Used:** What: Use Raycast's built-in `trash()` method instead of implementing custom trash functionality with system commands.  Why: Raycast's `trash()` method is safer, cross-platform compatible, and integrates properly with the Raycast environment.  Good: ... ([source](https://app.greptile.com/raycast/github/raycast/extensions/-/custom-context?memory=5c4ec1d2-9af4-490f-becf-e0030e4ba525))

**Knowledge Base Used:** [Extension command implementation patterns](https://app.greptile.com/raycast/-/custom-context/knowledge-base/raycast/extensions/-/docs/extension-command-implementation.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread extensions/app-uninstaller/package.json Outdated
}
],
"dependencies": {
"@raycast/api": "^2.4.1",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 @raycast/api is declared and locked at 2.4.1. A rollback three days ago found that this API version made a Store listing uninstallable because no released Raycast build supported it; 2.2.1 restored installs. This command uses no feature shown to need 2.4.1. Pin the manifest and lockfile to the released API.

Knowledge Base Used: Roll Back Secret Browser Commands API Version

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/app-uninstaller/package.json
Line: 33

Comment:
`@raycast/api` is declared and locked at 2.4.1. A rollback three days ago found that this API version made a Store listing uninstallable because no released Raycast build supported it; 2.2.1 restored installs. This command uses no feature shown to need 2.4.1. Pin the manifest and lockfile to the released API.

**Knowledge Base Used:** [Roll Back Secret Browser Commands API Version](https://app.greptile.com/raycast/-/custom-context/knowledge-base/raycast/extensions/-/reverts/rollback_31151-20260915-api-version-too-new-4b15289.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread extensions/app-uninstaller/src/lib/locations.ts Outdated
- Pin @raycast/api to 2.2.1; 2.4.1 is not runnable by any released
  Raycast build, which makes a listing uninstallable (see raycast#31151)
- Privileged removal now acts on the explicit selection, so an unsure
  match can no longer be moved as root without being chosen
- Move into a new folder in the Trash so mv -f cannot overwrite an item
  already there
- Drop /private/var/db/receipts as a search root; receipts stay on the
  pkgutil --forget path
@quentinved

Copy link
Copy Markdown
Contributor Author

Thanks — three of the four were real, and the API one was serious. Fixed in 6c12b09.

1. Admin action included unsure matches — fixed. This was the worst of the four: needsAdmin items were excluded from the default selection, but the action removed all of them regardless, so a low-confidence match could be moved as root without ever being chosen. That directly contradicted the extension's own stated invariant. Admin items are now selectable like everything else, unsure ones stay unselected, and the confirmation lists every path before the password prompt.

2. Trash collisions — fixed. A privileged move now targets a new, empty folder in the Trash named after the app, created as the user before escalating. mv -f has nothing to overwrite, the folder stays user-owned so the Trash still empties without a prompt, and one uninstall's files stay together if they need putting back.

3. @raycast/api 2.4.1 — fixed, and thank you. I checked #31151 and you're right: 2.4.1 was published on 14 Sept, no released Raycast build runs it, and a listing shipped against it is uninstallable. Pinned to ^2.2.1 in both manifest and lockfile. Nothing here uses an API surface newer than 2.2 — tsc, ray lint, ray build and the tests are all clean at 2.2.1. Worth noting this contradicts the published guideline to "use the latest Raycast API version", which is what led me to 2.4.1 in the first place.

4. Receipts — fixed. /private/var/db/receipts is no longer a search root. pkgutil keeps a database alongside the .bom/.plist, so moving those files desyncs it. Receipts were already surfaced separately for pkgutil --forget; that's now the only route, with a test asserting the path is refused.


One point I'd push back on: the suggestion in #2 to "use Raycast's built-in trash() method instead of custom Trash commands, as the repository rule requires."

trash() is already used for everything it can handle — every leftover goes through it, and the custom path exists only for items it physically cannot move. Moving a directory to a different parent rewrites its .. entry, which needs write permission on the directory itself. An App Store app's bundle is drwxr-xr-x root:wheel, so trash() fails on it with a generic "could not be trashed" no matter which permissions are granted. Routing those through trash() would mean App Store apps simply cannot be uninstalled — the main thing this extension is for.

I also couldn't find the repository rule being referenced — nothing in .github/, CONTRIBUTING.md, or the store docs mentions trash(). Happy to comply if you can point me at it.

The escalation is narrow: a separate action, on an explicit selection, through macOS's own authorization dialog (the extension never sees the credential), with every path re-validated against the allow-list immediately before root acts and the whole batch refused if any one fails. No path is interpolated into the command — they arrive as argv and are quoted by AppleScript's quoted form of. tests/elevate.test.ts asserts unsafe paths are rejected before anything can escalate.

74 tests passing, lint and dist build clean.

The list now opens on a size view banded by magnitude, biggest first, and
can switch to a last-used view banded by age with never-used apps
leading. Spotlight knows the last-used date for only about a quarter of
installed apps, so the rest is estimated from when the app last wrote its
own data; the two are labelled differently rather than presented alike.
Comment thread extensions/app-uninstaller/src/lib/views.ts
A date ahead of now made the elapsed time negative, so no age band
matched and grouping threw on an undefined bucket. Elapsed time is
clamped at zero, and the band helper falls back to its last band rather
than indexing with -1.
@quentinved

Copy link
Copy Markdown
Contributor Author

The new P2 was real — fixed in 91f1aec.

Future dates crashed the Last Used view. Confirmed by reproducing it before changing anything: a timestamp ahead of now makes the elapsed time negative, no AGE_BANDS entry matches, findIndex returns -1, and grouping throws Cannot read properties of undefined (reading 'push'). Not hypothetical either — the fallback path reads file mtimes, and a file restored from an archive or written under clock skew can easily be dated forwards.

Fixed twice over:

  • Elapsed time is clamped at zero, so a future date reads as used today rather than never.
  • Independently, the band helper falls back to its last band instead of indexing with -1, so a band table that stops being exhaustive degrades rather than taking the view down.

Tests cover both, plus the size path — which has no such gap today, but would have had the same failure mode.


Findings 1 and 2 in this round are stale — both were fixed in 6c12b09, before this review ran:

  • @raycast/api 2.4.1 → pinned to ^2.2.1 in manifest and lockfile. You were right and it was the most serious of the four; thanks for catching it.
  • Fixed Trash/ destination → mv -f no longer targets the Trash root. Each privileged move goes into a new, empty folder created for that uninstall (~/.Trash/<App> <timestamp>), created as the user before escalating. There is nothing in it to overwrite, and it stays user-owned so the Trash still empties without a further prompt.

On the recurring suggestion to use trash() instead: it is already used for everything it can move — every leftover goes through it. The custom path exists only for bundles it physically cannot move. Moving a directory to a different parent rewrites its .. entry, which requires write permission on the directory itself; an App Store app's bundle is drwxr-xr-x root:wheel, so trash() fails on it regardless of which permissions are granted. Routing those through trash() would mean App Store apps cannot be uninstalled at all.

I also still can't find the repository rule being cited — nothing in .github/, CONTRIBUTING.md or the store docs mentions trash(). Genuinely happy to comply if someone can point me at it.

84 tests passing, lint and dist build clean.

@0xdhrv 0xdhrv added the difficulty:hard Review effort suggested by Review Hub (hard). label Sep 27, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

difficulty:hard Review effort suggested by Review Hub (hard). new extension Label for PRs with new extensions platform: macOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants