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

Relaunch apps via open as console user - #43842

Merged
allenhouchins merged 2 commits into
mainfrom
allenhouchins-fma-fix
Apr 22, 2026
Merged

allenhouchins merged 2 commits into
mainfrom
allenhouchins-fma-fix

Conversation

@allenhouchins

@allenhouchins allenhouchins commented Apr 21, 2026 •

Copy link
Copy Markdown
Member

This pull request updates the application quit and relaunch logic in the Homebrew ingester scripts to more robustly detect valid GUI user sessions and improve how applications are relaunched after installation. The main improvements ensure that actions are only attempted when a real user is logged in, and that relaunching applications works reliably in the correct user context, especially when running as root.

User session validation:

  • The checks for a valid GUI session in quit_application, quit_and_track_application, and relaunch_application have been expanded to skip actions if the console user is empty, root, or loginwindow, preventing attempts to interact with the GUI when no real user is logged in. [1] [2] [3]

Application relaunch improvements:

  • The relaunch_application logic now uses launchctl asuser with sudo -u to launch the application in the correct user's GUI session, ensuring that the app appears in the user's Dock and GUI, even when the script runs as root. This replaces the previous approach of using osascript, which could fail in root contexts. [1] [2]
  • Additional comments were added to explain why these changes are necessary and how the new approach works. [1] [2]

Summary by CodeRabbit

  • Bug Fixes
    • More reliable app relaunch after installations, reducing failures when GUI apps are reinstalled.
    • Avoids attempting to quit or relaunch when no active console user is present (including the system login window), preventing unintended actions.
    • Uses a more robust method to launch apps in the user GUI session and reports success based on the launcher outcome for clearer results.

Ensure GUI apps are relaunched in the logged-in user's session instead of root. Update relaunch_application to skip when no non-root GUI user is present (empty/"root"/"loginwindow"), and use `sudo -u "$console_user" open -b <bundle_id>` when the installer runs as root so the app appears in the user's Dock/GUI. Remove the previous osascript activation attempt and add status handling and explanatory comments.
@codecov

codecov Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 66.92%. Comparing base (a060f3e) to head (272e653).
⚠️ Report is 40 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #43842      +/-   ##
==========================================
- Coverage   66.92%   66.92%   -0.01%     
==========================================
  Files        2603     2603              
  Lines      209044   209073      +29     
  Branches     9235     9235              
==========================================
+ Hits       139905   139913       +8     
- Misses      56392    56413      +21     
  Partials    12747    12747              
Flag Coverage Δ
backend 68.70% <ø> (-0.01%) ⬇️

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

☔ View full report in Codecov by Sentry.
📢 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 marked this pull request as ready for review April 21, 2026 13:54
@allenhouchins
allenhouchins requested a review from a team as a code owner April 21, 2026 13:54
Copilot AI review requested due to automatic review settings April 21, 2026 13:54
fleet-release
fleet-release previously approved these changes Apr 21, 2026

@claude claude Bot 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.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

The embedded shell functions in the Homebrew ingester were updated. The quit_application() and quit_and_track_application() skip conditions now also skip when console_user is empty or equals loginwindow, in addition to the existing EUID=0 && console_user=root check. The relaunch_application() implementation changed from using osascript ... to activate to launching the bundle with open -b <bundle_id>; when running as root it uses launchctl asuser <console_uid> sudo -u <console_user> open -b <bundle_id>. Success/failure is now based on open exit status.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description provides clear context about the changes, improvements made, and references to specific code locations, but lacks several required checklist items from the template (changes files, testing, database migrations, etc.). Complete the required checklist sections including whether a changes file was added, testing was performed, and other applicable requirements from the template.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: using the open command to relaunch apps in the console user's context instead of root.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch allenhouchins-fma-fix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ee/maintained-apps/ingesters/homebrew/scripts.go`:
- Around line 649-656: Replace the direct sudo -u "$console_user" open -b
"$bundle_id" call with the same launchctl asuser pattern used in send_signal so
the open runs inside the console user's Mach/GU I namespace; specifically, when
EUID is 0 invoke /bin/launchctl asuser "$console_uid" sudo -u "$console_user"
open -b "$bundle_id" (preserving the >/dev/null 2>&1 || open_status=$? handling
and the open_status variable), otherwise keep the non-root open path unchanged;
reference the existing send_signal use of /bin/launchctl asuser to mirror its
context-bootstrapping approach.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ab8a3fe6-3f00-475a-adfd-ed470012d327

📥 Commits

Reviewing files that changed from the base of the PR and between a8e90a8 and 1d6c482.

📒 Files selected for processing (1)
  • ee/maintained-apps/ingesters/homebrew/scripts.go

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.

Pull request overview

Updates the Homebrew install-script generator so previously-running GUI apps are relaunched in the logged-in user’s session (rather than root), improving Dock/GUI visibility and reliability when installers run as root.

Changes:

  • Add explanatory comments describing why relaunch must happen in the console user’s GUI session.
  • Update relaunch_application to skip relaunch when no non-root GUI user is present (empty/root/loginwindow).
  • Replace osascript ... activate relaunching with open -b, using sudo -u "$console_user" when running as root and capturing exit status.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@allenhouchins
allenhouchins marked this pull request as draft April 21, 2026 16:11

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ee/maintained-apps/ingesters/homebrew/scripts.go (1)

663-667: ⚠️ Potential issue | 🟡 Minor

open -b exit status is not a strong signal of successful GUI relaunch.

/usr/bin/open can return 0 even when LSOpenURLsWithRole() ultimately fails to surface the app in the Dock (e.g., stale Launch Services cache, bundle not yet re-registered after the move/install), and can return non-zero transiently right after a fresh install when the new bundle hasn't been indexed. Consider either (a) polling pgrep -f "$bundle_id" for a few seconds after open as a liveness check (mirroring the quit loop), or (b) softening the failure message to something like "relaunch requested" to avoid misleading log output. Not blocking — just flagging that the success/failure log may not always reflect reality.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/maintained-apps/ingesters/homebrew/scripts.go` around lines 663 - 667, The
current check uses open_status from the `open -b` call to declare
success/failure for relaunching `bundle_id`, which can be unreliable; update the
relaunch logic in the block that inspects `open_status` to either (a) poll for
the process after calling `open` by running `pgrep -f "$bundle_id"` in a short
loop for a few seconds (mirror the quit loop behavior) and treat presence as the
true success signal, or (b) change the echoed failure message around `bundle_id`
to a non-definitive string like "relaunch requested" so we don't claim a
successful GUI relaunch based solely on `open_status`; modify the code around
the `open_status` check and echo statements to implement one of these
approaches.
🧹 Nitpick comments (1)
ee/maintained-apps/ingesters/homebrew/scripts.go (1)

654-661: Guard against empty console_uid before invoking launchctl asuser.

If id -u "$console_user" fails (e.g., directory service hiccup, user removed between checks), console_uid will be empty and the call becomes /bin/launchctl asuser "" sudo -u "$console_user" open -b "$bundle_id", which will emit a confusing launchctl error rather than the intended "Failed to relaunch" message. The upstream non-empty check on console_user doesn't cover id failure.

🛡️ Suggested hardening
   if [[ $EUID -eq 0 ]]; then
     local console_uid
     console_uid=$(id -u "$console_user")
-    /bin/launchctl asuser "$console_uid" sudo -u "$console_user" open -b "$bundle_id" >/dev/null 2>&1 || open_status=$?
+    if [[ -z "$console_uid" ]]; then
+      echo "Could not resolve UID for console user '$console_user'; skipping relaunch."
+      return
+    fi
+    /bin/launchctl asuser "$console_uid" sudo -u "$console_user" open -b "$bundle_id" >/dev/null 2>&1 || open_status=$?
   else
     open -b "$bundle_id" >/dev/null 2>&1 || open_status=$?
   fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/maintained-apps/ingesters/homebrew/scripts.go` around lines 654 - 661, The
call to `/bin/launchctl asuser` can receive an empty `console_uid` if `id -u
"$console_user"` fails; update the block that computes `console_uid` and invokes
`/bin/launchctl asuser` to validate `console_uid` is non-empty (and preferably
numeric) before using it. If `console_uid` is empty/invalid, avoid calling
`launchctl asuser` and instead set `open_status` to a failure value (or fall
back to the non-root `open -b "$bundle_id"` path) and emit the existing "Failed
to relaunch" handling so a clear error is produced; refer to the `console_uid`,
`console_user`, and `/bin/launchctl asuser` usage in this section when making
the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@ee/maintained-apps/ingesters/homebrew/scripts.go`:
- Around line 663-667: The current check uses open_status from the `open -b`
call to declare success/failure for relaunching `bundle_id`, which can be
unreliable; update the relaunch logic in the block that inspects `open_status`
to either (a) poll for the process after calling `open` by running `pgrep -f
"$bundle_id"` in a short loop for a few seconds (mirror the quit loop behavior)
and treat presence as the true success signal, or (b) change the echoed failure
message around `bundle_id` to a non-definitive string like "relaunch requested"
so we don't claim a successful GUI relaunch based solely on `open_status`;
modify the code around the `open_status` check and echo statements to implement
one of these approaches.

---

Nitpick comments:
In `@ee/maintained-apps/ingesters/homebrew/scripts.go`:
- Around line 654-661: The call to `/bin/launchctl asuser` can receive an empty
`console_uid` if `id -u "$console_user"` fails; update the block that computes
`console_uid` and invokes `/bin/launchctl asuser` to validate `console_uid` is
non-empty (and preferably numeric) before using it. If `console_uid` is
empty/invalid, avoid calling `launchctl asuser` and instead set `open_status` to
a failure value (or fall back to the non-root `open -b "$bundle_id"` path) and
emit the existing "Failed to relaunch" handling so a clear error is produced;
refer to the `console_uid`, `console_user`, and `/bin/launchctl asuser` usage in
this section when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8ce7520b-d65c-47bf-90af-484e3a00b355

📥 Commits

Reviewing files that changed from the base of the PR and between 1d6c482 and 272e653.

📒 Files selected for processing (1)
  • ee/maintained-apps/ingesters/homebrew/scripts.go

@allenhouchins
allenhouchins merged commit f35fad2 into main Apr 22, 2026
48 of 49 checks passed
@allenhouchins
allenhouchins deleted the allenhouchins-fma-fix branch April 22, 2026 01:51
allenhouchins added a commit that referenced this pull request Jul 9, 2026
…pers (#48639)

The custom install script embedded pre-#42951/#43842 copies of
quit_and_track_application and relaunch_application. The stale
'if ! osascript' check treats any non-erroring osascript call as
"app is running" (osascript exits 0 whether it prints true or false),
so the app was marked for relaunch on every install and launched
after every patch, even from a fully-quit state.

Replace both functions with the current scripts.go constants
(output-based running check, launchctl-asuser relaunch, updated
console-user guards) and regenerate the darwin manifest.
allenhouchins added a commit that referenced this pull request Jul 9, 2026
…pers (#49030)

**Related issue:** Resolves #48639

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] 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.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

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

## Details

The GitHub Desktop FMA uses a custom install script
(`ee/maintained-apps/inputs/homebrew/scripts/github-desktop-install.sh`)
that embeds its own copies of `quit_and_track_application` and
`relaunch_application`. Those copies were frozen before two fixes landed
in the generated helpers in
`ee/maintained-apps/ingesters/homebrew/scripts.go`:

- #42951 — check osascript **output** instead of exit status. `osascript
-e '... is running'` exits 0 whether it prints `true` or `false`, so the
stale `if ! osascript ...` guard never fired. The app was marked
`APP_WAS_RUNNING=1` on **every** install with a GUI user logged in and
relaunched after every patch — even from a fully-quit state. This is the
root cause of #48639.
- #43842 — relaunch via `launchctl asuser ... open -b` as the console
user instead of `osascript ... to activate` (which is unreliable from a
root context), plus the updated empty/root/loginwindow console-user
guards.

This PR replaces both embedded functions with the current scripts.go
constants (verified byte-for-byte identical) and regenerates
`ee/maintained-apps/outputs/github/darwin.json` via `go run
./cmd/maintained-apps -slug github`. The manifest diff is
script-ref-only (`98ab6ed8` → `c91ea2b5`); version and uninstall script
are unchanged. The other five custom scripts (Docker Desktop, OpenVPN
Connect, Webex, Max, Pd) already carry the updated helpers — GitHub
Desktop was the only one missed.

## Manual QA

Tested the updated `quit_and_track_application` / `relaunch_application`
functions on macOS against GitHub Desktop itself
(`com.github.GitHubClient`):

- **Fully quit (the bug scenario):** verified `is running` returns
`false` and zero `GitHub Desktop.app` processes. Fixed functions set
`APP_WAS_RUNNING=0` and the app stays closed. Running the old shipped
check (`if ! osascript ...`) against the same state misclassifies the
app as running (osascript exits 0 with output `false`) and would have
relaunched it.
- **Running:** quit succeeds, `APP_WAS_RUNNING=1`, app relaunches
successfully afterward.
- `bash -n` passes on the updated script.

Note: hosts where the FMA was already added keep the baked `98ab6ed8`
script until their instance refreshes the manifest. The by-design
behavior "app running with dock icon but no visible window → relaunched
with a window" is unchanged; window-aware relaunching would be a
separate enhancement.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved GitHub Desktop installation behavior on macOS so the app is
more reliably closed and reopened after install.
* Better handles login/session edge cases, helping ensure the app
relaunches in the correct user’s desktop session.
* Reduces failed or missed relaunches when the installer is run with
elevated permissions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
allenhouchins added a commit that referenced this pull request Oct 9, 2026
**Related issue:** Part of #54170

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [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.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests

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

`TestInstallScriptStopsWhenAppWontQuit` runs generated install scripts
(app copy and pkg) in bash with `osascript`, `sudo` and friends stubbed.
It covers an app that isn't running, one that quits, one that accepts
the quit event but keeps running, and one whose quit is canceled. The
last two must exit 1 without copying or installing anything. The test
fails against `main` and passes with this change.

I also ran each edited custom install script end to end with stubs and a
refused quit. All of them exit 1 before replacing the app, except
Harmony SASE (see below). Docker Desktop's helper was checked under its
`set -euo pipefail` for both outcomes. Not run on a real Mac.

## AI

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

## Summary

When a macOS app won't quit (for example, the user cancels a prompt to
save changes), the install script moves it aside anyway, copies the new
version in, and exits 0. fleetd then deletes `$TMPDIR`, which takes the
moved-aside bundle the app is still running from. Fleet reports the
update as installed while the user is still on the old version (#54170).

Two problems in `quit_and_track_application` caused this:
- It confirmed the quit with `pgrep -f "$bundle_id"`, which never
matches because a macOS app's command line is its executable path. So it
reported success as soon as the quit event was accepted.
- When the app was still running at the timeout, it only logged "did not
quit", and the caller carried on.

Changes:
- The helper now polls `osascript … is running` after each quit attempt
and returns 1 at the timeout. This matches the KiCad FMA (#54613). An
`is running` error doesn't count as quit.
- Generated install scripts call `quit_and_track_application '<id>' ||
exit 1` before replacing the app, for both app-copy and pkg installs.
- Custom install scripts that copy the helper get the same fix. Callers
stop the install, and Pd detaches its disk image first. Docker Desktop
already stopped on failure (it runs under `set -e`) and only needed the
`pgrep` fix. **Harmony SASE** keeps going: its pkg force-quits the app
on its own, so stopping would block an update that works today.
- The `new-fma` skill now says how custom install scripts should quit
the app and that helper copies must be kept in sync.

Behavior change: a forced patch whose app won't quit now fails, and it's
retried on the next policy run instead of being reported as installed.
The yaml-files and REST API docs describe when the install is
*attempted* ("whether or not the app is open"), which is still accurate,
so they're unchanged.

Outputs aren't regenerated here: like #43842 and #42951, the FMA bot
picks up the new script refs. The 18 frozen macOS apps keep their
current scripts until they're unfrozen.

Follow-ups (not in this PR):
- 12 custom install scripts have their own `quit_application` helper
with the same `pgrep` check and no stop on failure: 1Password, Adobe
Creative Cloud, Box Tools, ExpressVPN, Google Chrome, Grammarly, Gyazo,
Logi Tune, Microsoft Edge, P4V, Slack, Zoom. Each has its own flow, so
they need a per-script look.
- The uninstall helper `quit_application` has the same `pgrep` check.
It's left unchanged here to avoid churning every uninstall script ref.
- #53570 changes the `var_name` line of the same helper, in a separate
hunk. The Wispr Flow custom script in #54708 copies the old helper and
needs this fix after whichever merges second.


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

## Summary by CodeRabbit

* **Bug Fixes**
* On macOS, updates now stop if the app being replaced doesn’t quit
within the timeout. The running app’s files are preserved, and the
update is reported as failed rather than installed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
allenhouchins added a commit that referenced this pull request Oct 20, 2026
…53570)

**Related issue:** NA — found while reviewing #53566

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`.
- [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.

## What & why

`quit_and_track_application` and `relaunch_application` derive the name
of the "was it running" flag from the bundle ID and interpolate it into
an `eval`:

```sh
local var_name="APP_WAS_RUNNING_$(echo "$bundle_id" | tr '.-' '__')"
eval "export $var_name=1"
```

`tr '.-' '__'` maps only dots and dashes. Any other character survives
into the variable name, and a space breaks the `eval` in two:

- **Write:** `eval "export APP_WAS_RUNNING_org_mozilla_pale moon=1"` —
`export` takes two arguments, so it sets
`APP_WAS_RUNNING_org_mozilla_pale` and a stray `moon=1`. The intended
variable is never set.
- **Read:** `eval "was_running=$APP_WAS_RUNNING_org_mozilla_pale moon"`
— assigns empty, then tries to run `moon` as a command, printing `moon:
command not found` into the install output.
- **Result:** `was_running` is empty, so `relaunch_application` returns
early.

**Pale Moon hits this today.** `pale-moon.json` has
`"unique_identifier": "org.mozilla.pale moon"`, and the shipped script
calls `quit_and_track_application 'org.mozilla.pale moon'`. The app is
quit during an update and never reopened. It is the only one of the 967
shipping macOS FMAs affected — every other identifier is drawn from
`[A-Za-z0-9._-]`.

This replaces the derivation with bash parameter expansion that maps
everything outside `[A-Za-z0-9_]`:

```sh
local var_name="APP_WAS_RUNNING_${bundle_id//[^[:alnum:]_]/_}"
```

Also applied to the thirteen custom install scripts under
`inputs/homebrew/scripts/` that carry their own copy of these functions,
so the two don't drift.

### Reviewer notes

- **Inert for every app but pale-moon.** For identifiers drawn from
`[A-Za-z0-9._-]` the new mapping produces a byte-identical variable name
to `tr '.-' '__'`. I compared old vs. new across all 967 homebrew
`unique_identifier` values: 966 identical, 1 changed (pale-moon, the
broken one). Runtime behavior for every other app is unchanged.
- **Why parameter expansion instead of `tr -c '[:alnum:]_' '_'`.** Same
result, but no subshell, and it sidesteps two hazards: `echo` appends a
newline that `tr -c` would map to a trailing underscore, and BSD vs. GNU
`tr` differences in CI. Verified on `/bin/bash` 3.2, the macOS runtime —
generated scripts have used a bash shebang since #44836.
- **No output regeneration here.** This changes a shared function body,
so all 956 generated scripts containing it will shift by two lines when
the nightly regenerates. That matches how prior changes to these same
functions landed (#43842, #42951, #44836 were all generator-only).
- **Not a security fix.** `unique_identifier` comes from Fleet's own
input files, not the brew API, so this isn't the untrusted-input class
#51324 addressed. Hardening the `eval` is still worthwhile because the
name reaches a root-privileged `eval`, but the bug being fixed is the
relaunch failure.

## Testing

- [x] Added/updated automated tests

`TestQuitRelaunchVarNameSurvivesEval` runs the real write/read round
trip through bash for four shipping identifier shapes including
`org.mozilla.pale moon`, and asserts the flag survives.
`TestQuitRelaunchVarNameNeutralizesCommandSubstitution` asserts the
derived name is a bare shell identifier and that a `$(touch ...)`
payload doesn't execute. `TestQuitAndRelaunchDeriveVarNameIdentically`
pins the two derivations to the same line so they can't drift, and
`TestCustomInstallScriptsDoNotUseOldVarNameDerivation` fails if any
custom install script still uses `tr '.-' '__'`.

Confirmed these fail against the old derivation: under `set -u`,
pale-moon errors with `APP_WAS_RUNNING_org_mozilla_pale: unbound
variable`; without it, the read back is silently empty.

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

`go test ./ee/maintained-apps/...` passes. `bash -n` clean on all
thirteen patched custom scripts. `go vet` and `gofmt` clean. (`make
lint-go-incremental` couldn't run locally — it needs a network clone of
golangci-lint — so leaving that to CI.)

## AI

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


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

* **Bug Fixes**
* Fixed macOS app updates so Fleet-maintained apps with spaces or other
special characters in their bundle identifiers can relaunch
successfully.
* Improved tracking of whether applications were running before
installation for identifiers containing special characters.

* **Tests**
* Added coverage for application tracking with special-character
identifiers.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
allenhouchins added a commit that referenced this pull request Oct 23, 2026
…one is logged in (#56457)

**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 #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 '<id>' 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 #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."
- `1password_uninstall.sh` is this branch's generated uninstall with one
change: `quit_application` is replaced by `quit_1password`. It sends
`pkill -TERM -x 1Password`, polls `is running` for up to 10s, and
returns 1 if 1Password is still running. The call is `quit_1password ||
exit 1`, right before `rm -rf`.
- `quit_1password` is the install script's `quit_and_track_1password`
(#56452) without the relaunch tracking.
- The script was built from this branch's generator, so it already has
the #56333 `trash()` and quoted call sites.

### 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

- #56459 (new-fma skill guidance, split out of #56337) should merge
after this PR.
- #55865 (`rmdir` with `~`) also changes `scripts.go` and many of the
same outputs. Whichever merges second needs a rebuild of the overlapping
outputs.

## 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`.

- **Generated outputs:** they have the #56333 and #56336 changes,
applied to the uninstall script that's on `main` today. The 116 outputs
the bot bumped since #56333 was cut were rebuilt from `main`'s file the
same way. LM Studio and Logitech G HUB were added after #56333 was cut,
so they're new in this PR. The edit reproduces all 955 generated outputs
in #56333 and #56336 byte for byte.
- **#56314's quit change isn't applied to generated outputs.** That
follows #56314 and the precedent of #55157, #43842 and #42951: the FMA
bot picks up the new uninstall refs for the apps that quit something.
For 35 of those apps, the bot also adds #56337's quit, for example
Excel, Outlook, PowerPoint and Teams.
- **#56337's quit is applied to 589 outputs.** None of them quits
anything yet, and each already matched this branch's generator. So each
gains only `quit_application '<unique_identifier>' || exit 1` and the
helper. Eight are frozen apps, which the bot never rewrites: BrickLink
Studio, Cisco Jabber, Comet, Mobirise, Twobird, Usage, Visual Paradigm
and Whispering. Angry IP Scanner, InVesalius and KeyboardCleanTool
weren't changed by the trash or directive edits, so they're new in this
PR.
- **Frozen outputs (14):** hand-edited with the trash and directive
changes, since ingest never rewrites them. Logitech G HUB is newly
frozen since #56333. As in #56314, frozen apps keep the old quit helper
until they're unfrozen. Avast Secure Browser and Intune Company Portal
also don't get #56337's quit until then.
- **Custom-script outputs:** each uninstall script equals its input
file. Microsoft Word was rebuilt on `main`'s newer version. 1Password's
uninstall now comes from its new custom script (#56453). `go run
./cmd/maintained-apps --slug=1password/darwin` matches the committed
output, and its install script is unchanged from `main`.
- **DeskTime, DisplayLink, Fathom, NotesOllama:** #56016's regenerated
quit line, plus the trash change.

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](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
  - None. The combined PRs' changes files were removed.

- [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-supplied paths, executables, args and string scripts still go
through `shellSingleQuote`. The only new interpolation is the quoted
`"$LOGGED_IN_USER"`.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests
- `TestUninstallScriptStopsWhenAppWontQuit`, `TestQuitTargetsApp`,
`TestCustomUninstallScriptsUseCurrentQuitHelper` (#56314)
  - `TestUninstallScriptQuitsAppByBundleIDWhenCaskListsNames` (#56016)
- `TestUninstallScriptTrashWithoutGUIUser`,
`TestCustomUninstallScriptsUseCurrentTrash` (#56333)
  - `TestUninstallScriptScriptDirectiveWithoutGUIUser` (#56336)
- `TestCustomInstallScriptsStopWhenAppWontQuit` checks every `quit_*`
call (#56454)
- `TestUninstallScriptQuitsAppCaskDoesNotName`,
`TestUninstallScriptQuitsAppOnce` (#56337), and a quit-by-name case in
`TestUninstallScriptQuitsAppByBundleIDWhenCaskListsNames` that fails if
the app is quit twice
- 1Password's uninstall (#56453): I ran it with `/bin/bash` 3.2 and
`osascript`, `pkill`, `sudo`, `launchctl`, `stat`, `scutil`, `mv` and
`rm` stubbed. The `osascript` stub ignores quit events, as 1Password
does in menu-bar mode. Not running: no `pkill`, the app is removed, exit
0. Quits on SIGTERM: one `pkill -TERM -x 1Password`, the app is removed,
exit 0. Ignores SIGTERM: "did not quit", exit 1, nothing is removed.
shellcheck reports the same warnings as the generated script. The
SIGTERM behavior was checked on a real Mac for #56452.
- `go test ./ee/maintained-apps/... ./cmd/maintained-apps/...` passes
with Homebrew bash 5.3 and with `/bin/bash` 3.2 first on `PATH`.
golangci-lint (full and incremental) reports 0 issues on those packages.
Two incremental findings in #56314's tests now use `errors.AsType` and
`require.Contains`, like the other tests. A gosec G602 false positive in
#56337's order check is restructured.
- [ ] QA'd all new/changed functionality manually

## AI

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
Docker Hub — 272e6532 Deployed Apr 21, 2026 by allenhouchins via publish #85773
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.

4 participants