镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

macOS FMA uninstalls: stop when the app won't quit, and work when no one is logged in - #56457

Merged
allenhouchins merged 26 commits into
mainfrom
allenhouchins/combine-multiple-prs-84b553
Oct 23, 2026
Merged

allenhouchins merged 26 commits into
mainfrom
allenhouchins/combine-multiple-prs-84b553

Conversation

@allenhouchins

@allenhouchins allenhouchins commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Related issue: Combines #56016, #56314, #56333, #56336, #56337, #56453 and #56454 into one PR. Those PRs are closed in favor of this one. Follow-up to #55157 (#54170) and #54210. #56333 came up in review of #56085 (Logitech G HUB).

Summary

All seven change how macOS FMA scripts are generated or tested. Fleet runs these scripts as root at any time, including at the login window.

Quit apps by bundle ID when the cask lists names (#56016)

A few casks list app names in uninstall quit: instead of bundle IDs. tell application id "DeskTime" fails with -1728, so quit_application treated the app as not running and the script removed the bundle while the app was open. A dotless quit: value now becomes the app's unique_identifier, and repeated IDs in a stanza are quit once. A dotless value with * (such as *WaveLink*) is still a pattern. Affected: DeskTime, DisplayLink, NotesOllama and Fathom.

Stop the uninstall when the app won't quit (#56314)

The uninstall quit_application helper had the bug that #55157 fixed in the install helper. It confirmed the quit with pgrep -f "$bundle_id", which never matches, and only logged "did not quit" before sudo rm -rf removed the bundle.

  • quit_application now polls osascript … is running after a 1s sleep, and returns 1 at the 10s timeout. quit_matching_applications returns 1 if any match didn't quit.
  • Generated scripts add || exit 1 only when the quit ID names the app being uninstalled: its unique_identifier, case-insensitively, or a wildcard that matches it. Helpers, shared updaters such as Microsoft AutoUpdate, and other vendors' apps keep Homebrew's warn-and-continue. A helper that launchd relaunches would otherwise block the uninstall for good.
  • With Quit FMA apps by bundle ID when the cask's uninstall quit lists names #56016, a name in quit: becomes the app's own ID first, so DeskTime, DisplayLink, NotesOllama and Fathom stop too.
  • The six custom uninstall scripts with stale copies of the helper now carry the current one: Box Tools, CleanMyMac, ForkLift, GPG Suite, Microsoft Word and P4V.

Behavior change: if the app won't quit, the uninstall fails with "Application '' did not quit." It no longer removes the app while the app is running.

Quit the app when the cask doesn't (#56337)

Generated uninstall scripts only quit the IDs in the cask's uninstall quit:. Most casks list none, and Excel, Outlook, PowerPoint and Teams quit only Microsoft AutoUpdate. So the app could be removed while it was still running.

  • The generator now writes quit_application '<unique_identifier>' || exit 1 once, before anything removes the bundle. With an uninstall stanza, it goes at the stanza's quit step: after launchctl and the cask's own quits, and before signal, early_script, script, pkgutil and delete. Without one, it goes right before the app's rm -rf. A zap quit doesn't count, because zap runs after the app is removed.
  • It's left out when an uninstall stanza already quits or signals the app. 17 casks signal apps that don't quit cleanly, such as DBeaver, Parallels, Webex and Zoom for IT Admins. A quit that stops the script mustn't run ahead of the signal. Perimeter 81 splits its stanza across three artifacts, so every stanza is checked first.
  • A cask quit that lists the app's name counts as quitting the app, through Quit FMA apps by bundle ID when the cask's uninstall quit lists names #56016's mapping. So DeskTime, DisplayLink, Fathom and NotesOllama aren't quit twice.
  • Vendor script and early_script directives aren't exempt. A directive doesn't say whether it stops the app, and the install script already quits the same ID with || exit 1 on every update.
  • Some apps' unique_identifier differs from the cask's quit ID, such as KeePassXC, CleanShot X, Tower, JASP and Sequel Ace. They now also quit the unique_identifier, which is the ID Fleet sees installed. If LaunchServices doesn't know that ID, the helper returns 0.

The new-fma skill's guidance for custom uninstall scripts moved to #56459.

Trash cleanup with no GUI user (#56333)

At the login window, LOGGED_IN_USER is loginwindow, so trash() tried to move every path into /Users/loginwindow/.Trash and failed. When the user was empty, the unquoted variable disappeared and the arguments shifted. Either way, system paths like /Users/Shared/LGHUB were left behind.

  • Generated calls quote the user: trash "$LOGGED_IN_USER" '<path>'.
  • With no real console user (empty, root or loginwindow), ~ paths are skipped with Not logged into a non-root GUI; skipping <path>. Other paths are deleted with rm -rf, and globs still expand through compgen -G. These are only the cask's app-specific zap entries. None of the absolute paths in the outputs is a shared parent folder.
  • Cleanup stays best-effort. A failed removal is logged, and trash() always returns 0. Before, the exit status depended on which path happened to be last.
  • Nine custom uninstall scripts carry the current trash() verbatim, with quoted call sites: Camtasia, CleanMyMac, Cycling '74 Max, ForkLift, GPG Suite, Homebrew, KiCad, Microsoft Word and Teleport.

Script directives with no GUI user (#56336)

Cask script and early_script directives were generated as (cd /Users/$LOGGED_IN_USER && …). At the login window the cd failed, so vendor uninstallers never ran (Anka, Cloudflare WARP, Chrome Remote Desktop Host, Microsoft Defender, Santa, Mullvad VPN and 11 more).

  • Executable directives run as root, so they now start in the user's home when there is one and in / otherwise: (cd "/Users/$LOGGED_IN_USER" 2>/dev/null || cd /; sudo '<exe>' …). must_succeed: false still appends || true.
  • String directives run as the user, so they're skipped with the same "Not logged into a non-root GUI" message when there's no real user. Box Drive is the only output with one.
  • Box Drive's pre_uninstall_scripts run their per-user steps only when someone is logged in. With an empty user, the old sudoers line wasn't valid syntax. GPG Suite's custom uninstaller call uses the same cd … || cd / form.

Quit 1Password with SIGTERM in the uninstall (#56453)

With "Keep 1Password in the menu bar" on (the default), 1Password's Electron before-quit handler cancels a quit event and drops to the menu bar. Its SIGTERM handler quits it fully, the same as Quit in its menu bar menu. Without a custom script, the bot would regenerate 1Password's uninstall with quit_application 'com.1password.1password' || exit 1, and every uninstall with 1Password running would fail with "did not quit."

Check every quit_* call in custom install scripts (#56454)

TestCustomInstallScriptsStopWhenAppWontQuit only matched quit_application and quit_and_track_application. It now matches any quit_* call, so a script with its own quit function, like quit_and_track_1password (#56452), can't drop || exit 1 unnoticed. qgis-ltr-install.sh is allowlisted: its quit_app_at_path sends SIGTERM, then SIGKILL after 10 seconds.

Merge order

Outputs

975 of 980 macOS outputs change. In each one, only the uninstall script and its ref change. Version, URL, sha256, install script and queries are byte-identical to main.

Generator cross-check: I ran the combined ingester for 20 apps, including the four from #56016, LM Studio, Logitech G HUB, Box Drive, Cloudflare WARP, Docker Desktop, the custom-script apps and a bot-bumped app. Every uninstall script is identical to this PR's, except for #56314's quit helpers and || exit 1 on the app's own quit, which the bot adds.

#56337 cross-check: I generated all 964 non-custom macOS uninstall scripts from today's cask API with this branch's generator, before and after #56337. 624 change, each by one quit_application '<unique_identifier>' || exit 1 line, plus the helper in the 587 that didn't have it. The next statement is always a removal: sudo rm -rf (553), remove_pkg_files (60) or the cask's uninstall script (8). DeskTime, DisplayLink, Fathom and NotesOllama don't change. For Asana, Google Chrome, Microsoft Defender and Obsidian, go run ./cmd/maintained-apps produces the same uninstall script as the committed output.

Checklist for submitter

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

    • None. The combined PRs' changes files were removed.
  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.

    • Cask-supplied paths, executables, args and string scripts still go through shellSingleQuote. The only new interpolation is the quoted "$LOGGED_IN_USER".
  • Timeouts are implemented and retries are limited to avoid infinite loops

Testing

AI

AI: Claude Code (claude-opus-5-5)

allenhouchins and others added 18 commits October 20, 2026 09:20
Some Homebrew casks list app names in uninstall quit (DeskTime, DisplayLink,
NotesOllama). osascript's application id only resolves bundle IDs, so the
generated quit_application call errored and the app was never quit before
its bundle was removed. Quit the app's own bundle ID instead, deduplicated.
…6-merge

# Conflicts:
#	ee/maintained-apps/outputs/desktime/darwin.json
#	ee/maintained-apps/outputs/displaylink/darwin.json
#	ee/maintained-apps/outputs/notesollama/darwin.json
A quit value like "*WaveLink*" has no dot but is a pattern, so it should
still expand against running apps rather than collapse to the main
bundle ID.
The uninstall quit_application helper confirmed a quit with
pgrep -f "$bundle_id", which never matches, and carried on to rm -rf the
bundle when the app kept running. It now polls `is running` and returns 1
at the timeout, like quit_and_track_application (#55157).

Generated uninstall scripts stop on a failed quit only for the app's own
bundle ID (case-insensitive, wildcards included). Helpers, shared updaters
and other vendors' apps keep Homebrew's warn-and-continue.

Custom uninstall scripts with stale helper copies are synced, and their
outputs updated. Generated outputs refresh via the FMA bot.

Co-Authored-By: Claude <noreply@anthropic.com>
Quote $LOGGED_IN_USER in generated trash calls. When the console user is
empty, root or loginwindow, trash() skips ~ paths and deletes system paths
with rm -rf instead of moving them into a Trash that doesn't exist. Removal
failures are reported but never fail the uninstall.

Sync the trash() copies in custom uninstall scripts and update all macOS
outputs, including frozen ones.
Generated script/early_script directives started with
`cd /Users/$LOGGED_IN_USER &&`, so at the login window the cd failed and
the vendor uninstaller never ran, and an empty user ran string scripts
with `sudo -u ""`. Root-run executables now fall back to / when there's
no home to cd into; string scripts, which run as the user, are skipped
with a message. Box Drive's per-user pre-uninstall steps and GPG Suite's
custom uninstaller get the same treatment.
Generated uninstall scripts only quit what the cask's uninstall quit
stanza lists. Most casks list nothing, or only helpers and updaters
(Microsoft Office apps quit only Microsoft AutoUpdate), so the app's
bundle was removed while the app was still running.

The script now quits the app's unique_identifier, stopping if it won't
quit, before anything removes the bundle: at the quit step of the
uninstall stanza (after launchctl, before scripts, pkgutil and delete),
or before the app artifact when there's no stanza. It's left out when
an uninstall stanza already quits the app (case-insensitive, wildcards
included) or signals it: casks signal apps that don't quit cleanly, so
a stopping quit mustn't run ahead of the signal.

Generated outputs refresh via the FMA bot.

Co-Authored-By: Claude <noreply@anthropic.com>
TestCustomInstallScriptsStopWhenAppWontQuit only matched quit_application
and quit_and_track_application, so a custom-named quit function could
carry on after a failed quit unnoticed. Match any quit_* call, and allow
qgis-ltr-install.sh, whose quit_app_at_path falls back to SIGKILL.
…-by-name' into allenhouchins/combine-multiple-prs-84b553
…8312' into allenhouchins/combine-multiple-prs-84b553

# Conflicts:
#	ee/maintained-apps/outputs/microsoft-word/darwin.json
…ves-no-gui-user' into allenhouchins/combine-multiple-prs-84b553

# Conflicts:
#	ee/maintained-apps/ingesters/homebrew/scripts.go
#	ee/maintained-apps/ingesters/homebrew/scripts_test.go
#	ee/maintained-apps/outputs/1password/darwin.json
#	ee/maintained-apps/outputs/ableton-live-suite/darwin.json
#	ee/maintained-apps/outputs/acorn/darwin.json
#	ee/maintained-apps/outputs/airbuddy/darwin.json
#	ee/maintained-apps/outputs/airparrot/darwin.json
#	ee/maintained-apps/outputs/alt-tab/darwin.json
#	ee/maintained-apps/outputs/apidog/darwin.json
#	ee/maintained-apps/outputs/arc/darwin.json
#	ee/maintained-apps/outputs/asana/darwin.json
#	ee/maintained-apps/outputs/balenaetcher/darwin.json
#	ee/maintained-apps/outputs/bdash/darwin.json
#	ee/maintained-apps/outputs/beeper/darwin.json
#	ee/maintained-apps/outputs/bettertouchtool/darwin.json
#	ee/maintained-apps/outputs/bitwarden/darwin.json
#	ee/maintained-apps/outputs/blender/darwin.json
#	ee/maintained-apps/outputs/brave-browser/darwin.json
#	ee/maintained-apps/outputs/bruno/darwin.json
#	ee/maintained-apps/outputs/calibre/darwin.json
#	ee/maintained-apps/outputs/canva/darwin.json
#	ee/maintained-apps/outputs/cardhop/darwin.json
#	ee/maintained-apps/outputs/chatgpt/darwin.json
#	ee/maintained-apps/outputs/claude/darwin.json
#	ee/maintained-apps/outputs/cleanmymac/darwin.json
#	ee/maintained-apps/outputs/clion/darwin.json
#	ee/maintained-apps/outputs/clipbook/darwin.json
#	ee/maintained-apps/outputs/codexbar/darwin.json
#	ee/maintained-apps/outputs/cog-app/darwin.json
#	ee/maintained-apps/outputs/companion/darwin.json
#	ee/maintained-apps/outputs/crashplan/darwin.json
#	ee/maintained-apps/outputs/cursor/darwin.json
#	ee/maintained-apps/outputs/dangerzone/darwin.json
#	ee/maintained-apps/outputs/dataflare/darwin.json
#	ee/maintained-apps/outputs/dayflow/darwin.json
#	ee/maintained-apps/outputs/deepl/darwin.json
#	ee/maintained-apps/outputs/deezer/darwin.json
#	ee/maintained-apps/outputs/desktime/darwin.json
#	ee/maintained-apps/outputs/discord/darwin.json
#	ee/maintained-apps/outputs/displaylink/darwin.json
#	ee/maintained-apps/outputs/docker-desktop/darwin.json
#	ee/maintained-apps/outputs/elgato-control-center/darwin.json
#	ee/maintained-apps/outputs/emclient/darwin.json
#	ee/maintained-apps/outputs/fastmail/darwin.json
#	ee/maintained-apps/outputs/fathom/darwin.json
#	ee/maintained-apps/outputs/fellow/darwin.json
#	ee/maintained-apps/outputs/firefox@nightly/darwin.json
#	ee/maintained-apps/outputs/forklift/darwin.json
#	ee/maintained-apps/outputs/franz/darwin.json
#	ee/maintained-apps/outputs/funter/darwin.json
#	ee/maintained-apps/outputs/github-copilot-app/darwin.json
#	ee/maintained-apps/outputs/github/darwin.json
#	ee/maintained-apps/outputs/gitkraken/darwin.json
#	ee/maintained-apps/outputs/gpg-suite/darwin.json
#	ee/maintained-apps/outputs/grok-bot/darwin.json
#	ee/maintained-apps/outputs/groove-omnidialer/darwin.json
#	ee/maintained-apps/outputs/hey-desktop/darwin.json
#	ee/maintained-apps/outputs/houdahspot/darwin.json
#	ee/maintained-apps/outputs/jasp/darwin.json
#	ee/maintained-apps/outputs/jellyfin/darwin.json
#	ee/maintained-apps/outputs/jetbrains-toolbox/darwin.json
#	ee/maintained-apps/outputs/jitsi-meet/darwin.json
#	ee/maintained-apps/outputs/jump-desktop/darwin.json
#	ee/maintained-apps/outputs/kiro/darwin.json
#	ee/maintained-apps/outputs/lightburn/darwin.json
#	ee/maintained-apps/outputs/linear/darwin.json
#	ee/maintained-apps/outputs/lookaway/darwin.json
#	ee/maintained-apps/outputs/losslesscut/darwin.json
#	ee/maintained-apps/outputs/lunar/darwin.json
#	ee/maintained-apps/outputs/macwhisper/darwin.json
#	ee/maintained-apps/outputs/mailspring/darwin.json
#	ee/maintained-apps/outputs/marked-app/darwin.json
#	ee/maintained-apps/outputs/melodics/darwin.json
#	ee/maintained-apps/outputs/memory-cleaner/darwin.json
#	ee/maintained-apps/outputs/microsoft-edge/darwin.json
#	ee/maintained-apps/outputs/microsoft-excel/darwin.json
#	ee/maintained-apps/outputs/microsoft-onenote/darwin.json
#	ee/maintained-apps/outputs/microsoft-powerpoint/darwin.json
#	ee/maintained-apps/outputs/microsoft-teams/darwin.json
#	ee/maintained-apps/outputs/microsoft-word/darwin.json
#	ee/maintained-apps/outputs/monotype/darwin.json
#	ee/maintained-apps/outputs/notesnook/darwin.json
#	ee/maintained-apps/outputs/notesollama/darwin.json
#	ee/maintained-apps/outputs/notion-calendar/darwin.json
#	ee/maintained-apps/outputs/notion/darwin.json
#	ee/maintained-apps/outputs/obsidian/darwin.json
#	ee/maintained-apps/outputs/okta-advanced-server-access/darwin.json
#	ee/maintained-apps/outputs/okta-verify/darwin.json
#	ee/maintained-apps/outputs/ollama/darwin.json
#	ee/maintained-apps/outputs/opencode-desktop/darwin.json
#	ee/maintained-apps/outputs/orbstack/darwin.json
#	ee/maintained-apps/outputs/pdf-pals/darwin.json
#	ee/maintained-apps/outputs/postman/darwin.json
#	ee/maintained-apps/outputs/power-monitor/darwin.json
#	ee/maintained-apps/outputs/raindropio/darwin.json
#	ee/maintained-apps/outputs/raycast/darwin.json
#	ee/maintained-apps/outputs/reflector/darwin.json
#	ee/maintained-apps/outputs/rewritebar/darwin.json
#	ee/maintained-apps/outputs/rocket-chat/darwin.json
#	ee/maintained-apps/outputs/rowel/darwin.json
#	ee/maintained-apps/outputs/shapr3d/darwin.json
#	ee/maintained-apps/outputs/signal/darwin.json
#	ee/maintained-apps/outputs/snagit/darwin.json
#	ee/maintained-apps/outputs/spokenly/darwin.json
#	ee/maintained-apps/outputs/superhuman-docs/darwin.json
#	ee/maintained-apps/outputs/surfshark/darwin.json
#	ee/maintained-apps/outputs/sync/darwin.json
#	ee/maintained-apps/outputs/textexpander/darwin.json
#	ee/maintained-apps/outputs/trezor-suite/darwin.json
#	ee/maintained-apps/outputs/tuple/darwin.json
#	ee/maintained-apps/outputs/unity-hub/darwin.json
#	ee/maintained-apps/outputs/versions/darwin.json
#	ee/maintained-apps/outputs/virtualbox/darwin.json
#	ee/maintained-apps/outputs/warp/darwin.json
#	ee/maintained-apps/outputs/wavebox/darwin.json
#	ee/maintained-apps/outputs/wealthfolio/darwin.json
#	ee/maintained-apps/outputs/whatroute/darwin.json
#	ee/maintained-apps/outputs/workflowy/darwin.json
…ser' into allenhouchins/combine-multiple-prs-84b553
…-calls' into allenhouchins/combine-multiple-prs-84b553
With "Keep 1Password in the menu bar" on (the default), 1Password cancels
quit events and stays in the menu bar. Once the uninstall quit helper
reports a failed quit and stops the script, every uninstall with
1Password running would fail. Add a custom uninstall script that quits
1Password with SIGTERM, which it treats like Quit in its menu bar menu,
and stops before removing the app if it's still running after 10s.
…-own-app' into allenhouchins/combine-multiple-prs-84b553

# Conflicts:
#	ee/maintained-apps/ingesters/homebrew/scripts.go
#	ee/maintained-apps/ingesters/homebrew/scripts_test.go
A cask quit that lists the app's name is mapped to its bundle ID, so the own-app quit check treats it the same way. Without this, DeskTime, DisplayLink, Fathom and NotesOllama quit the app twice in a row.
These outputs already matched the generator, so only the own-app quit and its helper are added. Outputs that still carry the cask's quit with the old helper wait for the FMA bot, which adds both changes.

Copilot AI left a comment

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.

🔵 Needs a closer look

The catalog-wide changes to root-run removal scripts need final human review and end-to-end macOS validation beyond the supplied diffs.

No open issues 🎉

What changed in this PR

This PR improves Fleet-maintained macOS app uninstalls by checking that apps quit before removal and handling cleanup when no user is logged in. It extends the earlier install-side quit protection to uninstall generation and selected custom scripts.

Changes:

  • Adds quit-failure guards and refreshes affected uninstall scripts and references.
  • Updates trash cleanup and vendor-script execution for login-window operation.
  • Connects app metadata and the custom 1Password uninstaller to the ingestion flow.
File Description
ee/​maintained-apps/​outputs/​yattee/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​wordservice/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​wave/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​teacode/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​supportcompanion/​darwin.json Adds quit protection, cleanup updates, and vendor-script directory fallback.
ee/​maintained-apps/​outputs/​sirimote/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​pibar/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​p4v/​darwin.json Stops removal if any suite app fails to quit.
ee/​maintained-apps/​outputs/​mos/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​keycastr/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​keyboardcleantool/​darwin.json Adds a quit guard before bundle removal.
ee/​maintained-apps/​outputs/​jiggler/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​jami/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​invesalius/​darwin.json Adds a quit guard before bundle removal.
ee/​maintained-apps/​outputs/​hot/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​homebrew-app/​darwin.json Refreshes the custom trash helper.
ee/​maintained-apps/​outputs/​gpodder/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​outputs/​box-tools/​darwin.json Updates quit polling while retaining process-kill fallback.
ee/​maintained-apps/​outputs/​angry-ip-scanner/​darwin.json Adds a quit guard before bundle removal.
ee/​maintained-apps/​outputs/​alloy/​darwin.json Adds quit protection and session-aware cleanup.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​teleport-suite-uninstall.sh Updates trash handling and quotes the console user.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​p4v-uninstall.sh Verifies all three suite apps quit before removal.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​microsoft_word_uninstall.sh Refreshes quit polling, trash handling, and cleanup calls.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​kicad-uninstall.sh Updates session-aware trash handling and cleanup calls.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​homebrew-app-uninstall.sh Synchronizes the trash helper with updated cleanup behavior.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​gpg-suite-uninstall.sh Guards Keychain removal and updates cleanup and uninstaller execution.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​forklift-uninstall.sh Guards the main app’s quit and updates cleanup.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​cycling74-max-uninstall.sh Adds session-aware cleanup and glob handling.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​cleanmymac-uninstall.sh Guards the main app’s quit and updates cleanup.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​camtasia_uninstall.sh Synchronizes trash handling and quotes cleanup calls.
ee/​maintained-apps/​inputs/​homebrew/​scripts/​box-tools-uninstall.sh Replaces process-name polling with AppleScript running checks.
ee/​maintained-apps/​inputs/​homebrew/​box-drive.json Guards per-user steps and tolerates absent sudoers cleanup files.
ee/​maintained-apps/​inputs/​homebrew/​1password.json Selects the custom 1Password uninstall script.
ee/​maintained-apps/​ingesters/​homebrew/​ingester.go Passes app input metadata into uninstall generation.

Review effort: Balanced

@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.56%. Comparing base (8c15db9) to head (73ac7e4).
⚠️ Report is 82 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #56457      +/-   ##
==========================================
+ Coverage   77.52%   77.56%   +0.04%     
==========================================
  Files        4342     4377      +35     
  Lines      271392   272044     +652     
  Branches    15802    15877      +75     
==========================================
+ Hits       210404   211019     +615     
- Misses      60801    60838      +37     
  Partials      187      187              
Flag Coverage Δ
backend 78.77% <100.00%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@allenhouchins
allenhouchins merged commit 8f23b95 into main Oct 23, 2026
89 of 96 checks passed
@allenhouchins
allenhouchins deleted the allenhouchins/combine-multiple-prs-84b553 branch October 23, 2026 23:33
allenhouchins added a commit that referenced this pull request Oct 26, 2026
Install: apply #53570's var_name derivation to quit_and_track_application
and relaunch_application, so the copies match scripts.go byte-for-byte.
For com.github.wez.wezterm the derived name is unchanged.

Uninstall: rebuild wezterm-uninstall.sh from the current generator (#56457)
with only the rm -rf path changed to /Applications/WezTerm.app. It picks up
the login-window-safe trash() that TestCustomUninstallScriptsUseCurrentTrash
requires, and quit_application 'com.github.wez.wezterm' || exit 1 before the
bundle is removed. trash() now skips ~ paths with no GUI user, so the
LOGGED_IN_USER guard around the calls is no longer needed.

WezTerm is frozen, so swap the embedded scripts and refs in darwin.json by
hand: install 056ac45d -> 57c7d5cf, uninstall fce4539f -> 4db160b2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
allenhouchins added a commit that referenced this pull request Oct 26, 2026
…gnal (#56496)

**Related issue:** Follow-up to #56457 (#56314). Found while fixing CI
on the nightly FMA PR #56494.

## Summary

#56457 adds `|| exit 1` to a cask's own uninstall quit when the quit ID
names the app being uninstalled. It already leaves out the generated
quit when the stanza signals the app, because "a quit that stops the
script mustn't run ahead of the signal." That rule didn't cover the
cask's own quit. So a cask that quits the app and then signals it as a
fallback stops at the quit and never sends the signal.

BusyContacts failed validation in #56494 this way. Its cask is `quit:
com.busymac.busycontacts` plus `signal: ["KILL",
"com.busymac.busycontacts"]`. The quit didn't finish in 10s, so the
uninstall exited with "did not quit" before the KILL ran.

- When any uninstall stanza signals the app, the cask's own quit of the
app no longer gets `|| exit 1`. The quit warns and continues, and the
signal runs next, as in Homebrew. Then the script quits the app again
with `|| exit 1`, so it still stops if the signal didn't stop the app.
- `uninstallStopsApp` is split into `uninstallQuitsApp` and
`uninstallSignalsApp`. The generated quit is still left out in both
cases, so nothing changes for casks that only signal the app.

## Outputs

Only BusyContacts, from the cleanup below. 4 of the ~980 macOS outputs
quit and signal the same app: BusyContacts, JetBrains Toolbox, Pale Moon
and Secretive. For each one, the only change is that `|| exit 1` moves
from the app's quit line to a new quit line after the signal. I checked
this by regenerating all four with this branch's generator. Every other
generated uninstall script is byte-identical.

- JetBrains Toolbox, Pale Moon and Secretive passed validation in
#56494, so the nightly bot will pick up the change after this merges.
- #56494 gives BusyContacts a custom uninstall script so it could pass
without Go changes. The script is this generator's output plus one
comment line.

## Before merging

- [x] After #56494 merges, merge `main` into this branch. Then delete
`ee/maintained-apps/inputs/homebrew/scripts/busycontacts-uninstall.sh`,
remove `uninstall_script_path` from
`ee/maintained-apps/inputs/homebrew/busycontacts.json`, and regenerate
`outputs/busycontacts/darwin.json`. The regenerated uninstall loses the
comment line `# The cask sends KILL next if the app doesn't quit.` and
gains `quit_application 'com.busymac.busycontacts' || exit 1` after the
signal.

## Testing

- [x] Added/updated automated tests

`TestUninstallScriptQuitsAppOnce/cask_quits_and_signals_the_app` and
`TestUninstallScriptChecksAppAfterSignal` fail without the `scripts.go`
change and pass with it. The second runs the script with stubs: when the
signal kills the app, the app is removed; when the signal finds no
process, the script exits 1 before removing anything. `go test
./ee/maintained-apps/... ./cmd/maintained-apps/...` passes.

## AI

**AI:** Claude Code (claude-opus-5-5)



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved uninstall handling for apps with both quit and signal
actions. If the app remains open after the initial quit request,
uninstall can signal it and then checks whether it has quit. Uninstall
stops with an error if the app is still running afterward.
* **Changes**
* BusyContacts no longer uses a custom uninstall script; its uninstall
behavior is handled by the standard process.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
allenhouchins added a commit that referenced this pull request Oct 28, 2026
**Related issue:** Found while reviewing a CodeRabbit comment on
#55864 (OpenAudible's zap switched to `rmdir:
"~/Library/OpenAudible"`).

Generated macOS uninstall scripts emitted `sudo rmdir '~/...'` for cask
`rmdir` entries. The tilde is single-quoted, so it never expands: rmdir
always fails and the directory is always left behind. Uninstall scripts
have no `set -e`, so the failure was silent.

- New `remove_empty_dir` helper in the homebrew ingester. It resolves
`~` against the console user's home (the same `/Users/$LOGGED_IN_USER`
approach `trash` uses) and skips `~` paths when there's no GUI user: no
console user, root, or the login window, the same check `trash` makes
since #56457. It keeps rmdir's empty-only semantics: a
non-empty directory is left alone, so user data is never deleted (this
is why the CodeRabbit `trash` suggestion was declined). A missing or
non-empty directory is quiet and non-fatal. Absolute `rmdir` paths go
through the same helper for consistent quiet behavior.
- `rmdir` directives now run last. The generator collects them from the
`uninstall` and `zap` stanzas and writes them at the end of the script,
after every `trash` and post-uninstall script. This matches brew, which
defers `rmdir` until everything else has run (`deferred_directives` in
`abstract_uninstall.rb`). Deeper paths go first, so nested targets such
as Firefox's `~/Library/Caches/Mozilla/updates/Applications`, `updates`,
and `Mozilla` are all removed once empty. Before this change, `rmdir`
ran before `trash` in each stanza, so a folder such as
`~/Library/Application Support/AVAST Software` was still full when rmdir
tried it.
- Rewrote the uninstall scripts of the 55 darwin outputs that contain a
cask `rmdir` (48 with `~` paths, 7 absolute-only). Only
`uninstall_script_ref` and its `refs` entry change: no version, URL,
SHA, or install script changes. As of 2026-10-28 (after the latest merge
of main), 54 of those uninstall scripts are byte-identical to what the
ingester generates from the current casks. All are at main's versions
except unity-hub, where brew is at 3.22.5 and the uninstall script is
unchanged. docker-desktop, firefox@nightly, and jetbrains-toolbox were
rebuilt on main's bot bumps, and jetbrains-toolbox's script also carries
#56496's quit/signal change. The exception is
avast-secure-browser: it's frozen on a hand-edited pkg script, and its
`rmdir` lines were edited by hand on top of main's version. boltai and
openaudible gained `rmdir` entries in #55864, and bruno,
jasp, lightburn, postman, and workflowy gained them in
#56433. All seven were regenerated at main's versions.
- #56457 moved microsoft-auto-update's uninstall to a
custom script,
`inputs/homebrew/scripts/microsoft-auto-update-uninstall.sh`, copied
from the generated one with its `sudo rmdir
'~/Library/Caches/Microsoft…'` lines. That script gets the same fix:
helper added, `rmdir` moved to the end, `Microsoft/uls` before
`Microsoft`. No other custom script has the pattern.

Shared code changed: `ee/maintained-apps/ingesters/homebrew/scripts.go`,
plus the microsoft-auto-update custom uninstall script. The validator
will run on all 55 changed outputs.

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
(Cask paths are still emitted through `shellSingleQuote`; the helper
only quotes them as `"$dir"`.)

## Testing

- [x] Added/updated automated tests
  (`TestUninstallScriptRmdirResolvesHome` checks:
  - the generated lines
  - that `rmdir` runs after every `trash`, across entries and stanzas
  - deepest-first ordering
- running the generated script under bash: `~` expands to
`/Users/<console user>`, and `~` paths are skipped with no console user
or at the login window
- that an empty directory and a nested tree of empty directories are
removed, a non-empty one is kept, and a missing one is ignored, all with
no output and exit 0)
- [ ] QA'd all new/changed functionality manually
(All 55 rewritten scripts pass `bash -n`. Not yet run end-to-end on a
host.)

`go test ./ee/maintained-apps/... ./cmd/maintained-apps/...` passes.
golangci-lint (CI's pinned v2.14.0) reports 0 issues on the ingester,
both full and with `--new-from-merge-base=origin/main`.

## AI

**AI:** Claude Code (claude-opus-5-5)



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Updated macOS app uninstallers to resolve home-directory paths
correctly when removing empty folders.
* Empty-directory cleanup runs after items are moved to Trash and skips
home-relative paths when no eligible user is logged in.
* Uninstallations continue if a directory is missing, non-empty, or
cannot be removed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
allenhouchins added a commit that referenced this pull request Oct 31, 2026
…gnal-only casks too (#56585)

**Related issue:** Follow-up to #56496 and #56457 (#56314)

## Summary

#56457 stops a macOS FMA uninstall (`|| exit 1`) when the app being
uninstalled won't quit, so its bundle isn't removed while it's running.
It leaves out the generated quit when the cask signals the app, because
a quit that stops the script mustn't run before the signal. #56496 then
added a check after the signal, `quit_application '<bundle id>' || exit
1`, but only for casks that both quit and signal the app.

Casks that only signal the app still had no check. `send_signal` always
returns 0: it may find no PID through `launchctl list`, or the app may
ignore the signal. So the app's bundle could be removed while the app
was still running.

- Every signal to the app is now followed by the check, whether or not
the cask also quits the app. `quit_application` returns 0 right away
when the app isn't running or no GUI user is logged in, so the check is
a no-op when the signal worked.
- The check runs after the stanza's scripts and before anything is
removed. A script can stop the app by process name when the signal finds
no PID, like Parallels' `pkill -TERM prl_client_app`, so it gets to run
first.
- Signals to other bundle IDs (helpers, like NVIDIA GeForce NOW's
`com.nvidia.nvcontainer`) keep warn-and-continue.

## Outputs

17 macOS outputs signal the app without quitting it: Boost Note,
DarkModeBuddy, DBeaver Community/Enterprise/Lite/Ultimate, Elgato Camera
Hub, Franz, Harmony SASE, Macs Fan Control, Parallels, Pritunl,
TripMode, Viscosity, Webex, Zoom, and Zoom for Government. Each signal
ID matches the app's `unique_identifier` in its input.

I regenerated each one with `go run ./cmd/maintained-apps --slug
<slug>/darwin`. In each output, the uninstall script gains one line
after the app's `send_signal` (for Parallels, after the `pkill` that
follows it), plus the `quit_application` helper where the script didn't
already have it (Elgato Camera Hub already did). Nothing else changes
and no versions move. The quit-and-signal casks from #56496
(BusyContacts, JetBrains Toolbox, Pale Moon, Secretive) don't change.

## Testing

- [x] Added/updated automated tests

`TestUninstallScriptChecksAppAfterSignal` now covers a signal-only cask
alongside the quit-and-signal cask. It runs the generated script with
stubs. When the signal stops the app, the app is removed. When the
signal finds no process, or the app ignores the signal, the script exits
1 before removing anything. When the app isn't running, nothing is quit
and the app is removed.
`TestUninstallScriptQuitsAppOnce/cask_signals_the_app` now expects one
stopping quit after the signal and before the app is removed.
`TestUninstallScriptQuitsAppOnce/cask_signals_the_app_and_runs_a_script`
checks the Parallels order: signal, then the script, then the check,
then removal. These fail without the `scripts.go` change.

`go test ./ee/maintained-apps/... ./cmd/maintained-apps/...` passes, and
CI's pinned golangci-lint (v2.14.0) reports 0 issues.

- [ ] QA'd all new/changed functionality manually

## AI

**AI:** Claude Code (claude-opus-5-5)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Uninstallation of affected macOS apps now attempts to quit the app
before removing it. If the app remains open after the retry period,
uninstallation stops rather than removing the app and related data.
* Apps that are not running can still be removed without a quit attempt.
When an uninstall signals an app, it checks that the app has quit after
running the uninstall scripts and before proceeding with removal.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
allenhouchins added a commit that referenced this pull request Nov 1, 2026
…#57086)

Part 1 of 11, split from #56846. That PR changes 992 files, and the
auto-approval only covers PRs under 100 files. The 11 PRs touch disjoint
files, so they merge in any order without conflicts, and together they
match #56846's tree exactly. Merge this one first so the generator
change lands before, or with, the rest of the outputs.

Other parts:
- 2/11: #57087, `aldente` through `captain`
- 3/11: #57088, `capto` through `dia`
- 4/11: #57089, `dialpad` through `free-download-manager`
- 5/11: #57090, `front` through `julia-app`
- 6/11: #57091, `jump-desktop` through `microsoft-excel`
- 7/11: #57092, `microsoft-onenote` through `openboard`
- 8/11: #57093, `opencloud` through `raycast`
- 9/11: #57094, `readest` through `soundsource`
- 10/11: #57095, `sourcetree` through `twingate`
- 11/11: #57096, `typeface` through `zwift`

## Summary

Hardening for the `trash()` helper in generated macOS FMA uninstall
scripts. Fleet runs these scripts as root. `trash()` moved the cask's
`zap` paths into the console user's `~/.Trash` with a root `mv -f`.
Those paths and `~/.Trash` both belong to that user, so root shouldn't
be the one writing there.

- **Paths in the user's home (`~/...`)** are moved to the Trash as the
user, with `sudo -u "$logged_in_user" mv -f`. If the user's move fails,
the script logs `Failed to move <path> to the Trash.` and root doesn't
take over.
- **Other paths** are deleted as root by a new `remove_system_path`
helper instead of being moved into the user's Trash, such as
`/Library/LaunchDaemons/*.plist`, `/Library/PrivilegedHelperTools/*` and
`/Users/Shared/*`. The helper already deleted these at the login window
(#56457).
- **A symlinked parent folder can't redirect a root delete.** `rm -rf`
doesn't follow a symlink at the end of a path, but it does follow one in
a parent folder. For example, a user who creates
`/Users/Shared/TechSmith` as a symlink first could aim Camtasia's
`/Users/Shared/TechSmith/Camtasia` delete at a root-only folder.
`remove_system_path` uses `cd -P` to enter the real parent folder,
checks that it's the folder the path names, and deletes `./<name>` from
there, so a symlink swapped in after the check has no effect. Only
top-level symlinks such as `/tmp` and `/etc` are followed, because users
can't write to `/`. A path that fails the check is left in place and
logged as `Skipped <path>: a folder in its path is a symlink.`
- At the login window, `~` paths are still skipped, and cleanup stays
best-effort: `trash()` never fails the uninstall.

**Behavior change:** system paths listed in a cask's `zap trash:` are
deleted, not moved to the user's Trash. A system path with a symlink in
its parent folders below the top level is skipped.

A shell without Full Disk Access can still move files into `~/.Trash` by
name (only listing it is blocked), so the move as the user doesn't
depend on fleetd's TCC grants.

## Outputs

Across all 11 parts, 974 macOS outputs change. Each changes only its
uninstall script and `uninstall_script_ref`. Version, URL, sha256,
install script and queries are byte-identical to `main`.

This part has the generator, its tests, the 16 custom uninstall scripts,
and 73 outputs:

- **16 custom-script outputs**: each embeds its updated input script.
The custom scripts carry a copy of `trash()`, now the new one plus
`remove_system_path()`, including Citrix Workspace's, added on main in
#57081. `TestCustomUninstallScriptsUseCurrentTrash` keeps both identical
to the generator.
- **20 more frozen outputs**: the ingest job never rewrites these, so
they ship here. (WezTerm is both custom and frozen.)
- **37 generated outputs**, `010-editor` through `alcove`.

Parts 2–11 have the other 901 generated outputs. The outputs were built
and cross-checked against `cmd/maintained-apps` regeneration in #56846.

Because these outputs change, the darwin validator runs on each part's
apps. Expect some failures from apps that had already drifted upstream.
The Check Script Diff job will fail here: its comment is over GitHub's
65,536-character limit at this size. It isn't a required check.

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually

`TestUninstallScriptTrash` (renamed from
`TestUninstallScriptTrashWithoutGUIUser`) runs generated `trash` calls
in bash with the user's home under a temp dir. A bare `mv` is stubbed to
fail, so the test fails if root ever moves a file. It checks that:
- home paths really land in `~/.Trash` through `sudo -u`
- system paths are deleted
- a system path under a symlinked parent folder, given directly or
through a glob, is skipped, and the files the symlink points to stay in
place
- a failed user move is reported and nothing is removed as root

The test fails against the old helper, and also fails with the symlink
check in `remove_system_path` turned off.

Also checked:
- `go test ./cmd/maintained-apps/... ./ee/maintained-apps/...` passes on
this branch alone, and on each of parts 2–11 alone
- every custom script passes `bash -n`
- run directly on macOS, `remove_system_path` deletes a normal path,
skips one under a symlinked folder, removes a symlink at the end of the
path without touching its target, and follows `/tmp`

Not yet run as root by fleetd on a real Mac.

## AI

**AI:** Claude Code (claude-opus-5-5)

This branch was previously deployed

1 inactive deployment
Docker Hub — 73ac7e40 Deployed Oct 23, 2026 by allenhouchins via publish #110633
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.

3 participants