App Auto-Patch (AAP) is one of the best things to happen to third-party app patching on the Mac. It wraps Installomator with swiftDialog prompts, deferrals, and deadlines, so users get a friendly “these apps need updates” window instead of a silent install over a running app.

It also has a behavior that did not fit how I wanted to run it: every run rediscovers the world. If a user deferred, the next run found whatever had been released since, and the list kept changing. Updates shipped faster than people finished deferring, so the patch window never closed. Users got prompted for weeks, and the help desk heard about it.

I forked AAP at v3.5.0 to fix that. The fork is now at 3.11.8. This post covers the core idea and the rough edges I hit along the way.


The Problem: A Moving Target

The loop looks like this:

  1. Monday: AAP discovers Chrome and Slack are outdated and prompts the user.
  2. User defers.
  3. Wednesday: Zoom ships an update. The next run discovers Chrome, Slack, and Zoom.
  4. User defers again. Thursday: Teams updates.

The deadline logic works, but the list a user is deferring against never stops changing. There is no moment where “this cycle’s patches” is a fixed thing you can finish.


Accumulative Discovery

The fix is to separate finding updates from enforcing them, and to give the list a lifecycle:

  • Accumulate. Discovery runs daily, silently. It adds newly found labels to a cache but never removes anything.
  • Lock. On install day, the cache freezes. Users patch exactly that list.
  • Clear. When the user finishes, the cache clears and accumulation starts over.

Anything released after the lock is picked up for the next cycle. No update is skipped, and no cycle gets longer because of one.

The cache is a DiscoveredLabels array in AAP’s local preference plist, plus a DiscoveryLocked boolean. Adding to it is deliberately boring:

add_to_discovered_labels() {
    local label_to_add="$1"
    for existing_label in "${existingCachedLabels[@]}"; do
        if [[ "$existing_label" == "$label_to_add" ]]; then
            log_verbose "--- Label ${label_to_add} already in cache, skipping duplicate add"
            return 0
        fi
    done
    /usr/libexec/PlistBuddy -c "add \":DiscoveredLabels:\" string \"${label_to_add}\"" "${appAutoPatchLocalPLIST}.plist"
    existingCachedLabels+=("$label_to_add")
    log_notice "--- NEW: Added ${label_to_add} to discovery cache"
}

Three workflow flags drive the lifecycle:

FlagWhat it does
--workflow-discovery-onlyDiscover, add to the cache, exit. No dialog, no lock.
--workflow-lock-discoveryDiscover, lock, continue into the install dialog.
--workflow-unlock-discoveryClear the lock and the cache, then exit.

When discovery is locked, the main workflow skips discovery entirely and logs USING ACCUMULATED CACHE. Auto-unlock happens when the user completes patching, so the whole thing needs only two Jamf policies per audience: a daily silent discovery run, and a daily install run.

One gotcha: parts of my own README still describe --workflow-discovery-only as locking the cache. The code does not do that, and shouldn’t; only --workflow-lock-discovery locks. If discovery-only locked, daily accumulation would stop after the first run.


The Install-Day Gate

Locking requires knowing when “install day” is, and I did not want to create a new Jamf policy for every cadence. The install policy runs daily, and the script decides whether today counts. is_install_day() supports two cadences:

  • Monthly: the Nth weekday of the month (first Wednesday, for production), with an optional start time. Before the target, exit early. On or after it, proceed, so machines that were off on install day still catch up.
  • Weekly: an exact match on ISO day of week (date +%u) against PatchWeekStartDay.
if [[ "$today_iso" < "$target_date" ]]; then
    log_notice "INSTALL-DAY GATE: Monthly target is ${target_date}. Today (${today_iso}) is before install day."
    return 1
fi

The gate only applies when discovery is not yet locked. Once locked, the enforcement window is open and the gate steps aside, so a user on day four of a seven-day window is not blocked by “it’s not Wednesday.”

Production runs monthly with a seven-day deadline. A smaller test group runs weekly with a three-day deadline, so patches get exercised on a few Macs before the monthly lock. Same script, different managed preferences.


Bugs That Only Show Up in Production

Most of the changelog is fixes for problems I could not have found on a test Mac. A few were worth the pain:

  • cfprefsd desync (3.6.3). I was mixing defaults delete (which goes through the preferences daemon) with PlistBuddy (which writes the file directly). Intermittently, defaults read returned empty for the completion status, so AAP concluded patching had never happened and showed a forced-install dialog to people who were already current. The fix was to use one mechanism for deletion and recreation.
  • DST off-by-one (3.6.4). Day counts used local-time epoch math. When spring-forward fell inside a cycle, seven days measured as 6.96 and integer division rounded down to six. Switching to TZ=UTC date -j -f makes every day exactly 86,400 seconds.
  • Jamf check-ins deleting deferrals (3.6.4). A policy check-in refreshed AAP and wiped the saved deferral timer, so a user who deferred four hours got re-prompted minutes later. Startup now saves NextAutoLaunch and every early-exit path restores it.
  • Monthly cadence reset itself (3.7.0). A DaysUntilReset check meant for weekly cadence ran on monthly too, so a discovery-only run a day after patching un-completed the cycle and users were prompted again.

If you take one lesson from that list: with a daemon that relaunches itself and policies that call it from outside, every early-exit path is a place where state can be lost.


Custom Recipes

Installomator redownloads fresh on every run, and its install logic is one big case $label in ... esac baked in at release time. Dropping a file in fragments/labels/ gives AAP a display name and icon, but does not teach Installomator how to install anything.

deploy_custom_labels() does both halves after every Installomator install: it writes the label to fragments/labels/<name>.sh for AAP, and injects the same body into Installomator’s case statement so it can actually dispatch it.

if ! grep -q "^${customLabelName})" "${installomatorScript}" 2> /dev/null; then
    /usr/bin/sed -i.custombak "/^case \$label in\$/r ${fragmentFile}" "${installomatorScript}"
    log_info "Injected custom recipe '${customLabelName}' into Installomator"
fi

It is idempotent, so it survives Installomator being wiped and redownloaded. The label also has to be in RequiredLabels or OptionalLabels, or discovery will never queue it. It is an easy step to miss.


Background Helpers (a.k.a. the 1Password Saga)

Installomator’s BLOCKING_PROCESS_ACTION applies one action to a label’s whole process list, including the foreground app. Set it to something that kills processes and you can quit a user’s app out from under them. But some apps leave background processes after the window closes, and Installomator then loops on its quit prompt and exits with code 11.

3.9.0 added quit_background_helpers(), driven by a per-label map of exact process names:

backgroundHelperProcesses=(
    ollama "ollama"
    1password8 $'1Password\n1Password 8\n2BUA8C4S2C.com.1password.browser-helper\n1Password-BrowserSupport\n...'
)

It prompts once per app via swiftDialog, then stops each helper with launchctl bootout and falls back to kill -TERM. 1Password then took a string of point releases (3.10.1 through 3.11.8) to get right:

  • pgrep -x can’t see long names. macOS compares a truncated process name, so it never matched 2BUA8C4S2C.com.1password.browser-helper. Matching now goes through ps -axo pid=,command= and an awk check on the executable component.
  • pkill -f killed my own dialog. A swiftDialog progress message mentioned 1Password, so the broad match terminated swiftDialog. Targeting exact PIDs fixed it.
  • One confirmation per helper looked like a loop. It now confirms once per app, then stops everything it detected.
  • 1Password’s main process stays resident as a menu-bar item, so the map includes it, and AAP reopens the app after a successful update.

Cache-Only Self Service

Once the cache exists, it is useful on its own. --workflow-install-cached installs only what is already in DiscoveredLabels, skips discovery, and bypasses deferrals. In Jamf Self Service it is an Execute Command payload, so a user can say “update my apps now” without waiting for install day.

Getting it to feel instant took three releases. The LaunchDaemon handoff was restoring a stale future deferral, so Self Service looked like it did nothing (3.11.5). Then the handoff itself delayed the window, so a Jamf-launched cached install now runs inline (3.11.6). Finally, cache cleanup only happened when every app succeeded, so one failure made already-updated apps reappear on each run. Labels are now removed from the cache the moment they succeed:

if [[ "${cached_label}" == "${label_to_remove}" ]]; then
    /usr/libexec/PlistBuddy -c "delete :DiscoveredLabels:${cache_index}" "${appAutoPatchLocalPLIST}.plist"
    log_info "Removed successfully processed ${label_to_remove} from discovery cache"
    return 0
fi

Failures stay cached for retry.


What I’d Do Differently

The fork is one 6,000-line zsh file, and every upstream release means merging my changes by hand. The fork is based on 3.5.0. Keeping the changes narrow and well-commented has helped, but this is the real cost of forking.

The state machine lives in a plist and a handful of sentinel files, which is why so many of the bugs above are about state being lost or desynced. A single state file with one writer would be easier to reason about.

The 1Password regression tests are still a manual runbook. Reproducing an available update needs a disposable VM and a signed older installer, and that’s the next piece of work. After that, the process map should only grow for apps where I have captured real process samples, never from guesses.

If you run AAP and have hit the same loop, the idea is portable even if my code isn’t: accumulate daily, lock once, and let completion clear it. For how I decide when an update is urgent enough to skip all of this, see the Sentinel write-up.