Skip to content

alecschoen/updateNudge

Repository files navigation

macOS Update Nudge

A swiftDialog-based reminder tool that nudges users to install pending macOS updates before the DDM enforcement deadline forces a restart on them. It scales its urgency by how many days old the update is, never interrupts a meeting, lets people defer within reason, and warns clearly once the deadline has passed.

It is a companion to native Declarative Device Management (DDM) Software Update enforcement — not a replacement for it. DDM does the actual forced install/restart; this tool just makes sure nobody is taken by surprise.


The three files

File Where it goes What it is
install-update-nudge.sh Push via Jamf (Script policy) Self-contained installer. Embeds the other two and writes them out. This is the only file you strictly need to deploy.
update-nudge.sh /Library/Management/updateNudge/ The actual nudge logic. Written to disk by the installer; the standalone copy is for reference/editing.
shoes.alec.updateNudge.plist /Library/LaunchDaemons/ The LaunchDaemon that runs the script hourly. A configurable template — this is where you tune behaviour.

Because the installer embeds the script and a default copy of the plist, you can deploy the whole thing by pushing just install-update-nudge.sh. If you'd rather manage the plist separately in Jamf so you can tune settings without re-running the installer, push your own shoes.alec.updateNudge.plist afterward — it overwrites the installer's default and a daemon reload picks it up.


How it works

Deployment model: a LaunchDaemon, not a Jamf policy loop

The tool installs as a LaunchDaemon that runs once an hour. This matters: an earlier version ran as a recurring Jamf policy, but because the script can sit and wait out a meeting (see below), that held a Jamf policy execution open for up to two hours. As a daemon, the waiting is just a quiet local background process — Jamf's only job is the one-time install.

Each hourly run decides for itself whether now is the right moment to show anything (see scheduling below). Note that a run that shows nothing still performs a softwareupdate -l scan (a real network round-trip to Apple) — unless you enable the opt-in once-per-day check mode below.

Opt-in: checking once per day instead of every hour

With CheckOncePerDay set to true (profile key, or --check-once-per-day), a scan that comes back clean (no pending macOS update) is recorded, and every later run that day exits immediately — no network, no softwareupdated wake-up, done in milliseconds. The day's single scan waits for the daily-start anchor, so it runs at the first hourly run at/after the moment the first nudge could actually appear.

The daemon itself still runs hourly, deliberately — that heartbeat is what makes typed snooze times fire, lets busy-retries work, and keeps deadline days resilient. And the moment a scan does find a pending update, the clean marker stops being written and every run behaves exactly like the default mode: tiers, snooze, and call suppression are unaffected.

The trade-off: an update Apple releases after the day's clean scan isn't noticed until the next day. Since "days since release" is computed from Apple's release date, the deadline math stays correct — nudging just starts on day 1 instead of day 0.

A failed scan (offline, Apple unreachable) is never recorded as clean — only a scan that completed and genuinely found nothing writes the marker, so a Mac that was offline at check time is checked again the next hour, in both modes.

Autodetecting the real DDM deadline (default on)

Your MDM turns its enforcement rule ("latest OS, N days after release, install at HH:MM") into a per-release declaration with an exact computed deadline (TargetLocalDateTime) and pushes it to each Mac, where softwareupdated persists it locally. That on-device declaration is the ground truth of what will actually happen to that Mac — so by default the script reads it and derives the tier, the countdown, and the deadline shown in the dialogs from it. The configured enforce-day/enforce-time then serve only as a fallback for whenever no active declaration is readable: the Mac is already up to date, the declaration for a fresh release hasn't been pushed yet, the Mac isn't DDM-managed, or Apple changes the (undocumented) file format. Every run logs which source it used, and --check-only reports it.

Set AutoDetectEnforcement to false (or --auto-detect-enforcement=false) to always use the configured values instead.

Deciding whether there's anything to nudge about

On each run the script:

  1. Runs softwareupdate -l and looks specifically for a macOS update (Title: macOS ...). Updates that are only Xcode Command Line Tools, Safari, etc. are correctly ignored — no popup for those. A scan that fails to complete (offline, Apple's servers unreachable) is detected — the output must contain one of the scan's two positive completion markers — and treated as inconclusive: the run logs the failure and exits, the next hourly run retries, and no dialog is shown (nothing could be downloaded anyway).
  2. Parses the exact version (e.g. 26.5.2) from that line.
  3. Looks that version up in the SOFA feed (sofafeed.macadmins.io) to get its official Apple release date. This is the same date DDM counts from, so the tool's "days since release" matches the enforcement timeline. If the feed is unreachable, it falls back to a locally cached date; if there's no date at all, it degrades to a calm reminder.
  4. Computes days since release and compares it to enforce-day.

The three dialog tiers

Urgency scales with how close the deadline is:

Days since release Tier Look Buttons
0enforce-day − 1 Calm Green Open Software Update / Remind Me Later
= enforce-day Urgent Red triangle Open Software Update / Dismiss — I understand the risk
> enforce-day Overdue Red octagon Open Software Update (only)
  • Calm tells them an update is available and shows a "days until forced update" countdown. "Remind Me Later" opens a small follow-up where they can type a specific time for today (24h, e.g. 15:30), or leave it empty / press "Tomorrow Morning" to defer to the next day at the daily-start time. A time that has already passed today rolls to the same time tomorrow — see snooze below.
  • Urgent (the enforcement day itself) explains that macOS will auto-install and restart at enforce-time, or the next time the Mac is awake after that. The second button is a deliberate "I understand the risk" acknowledgement, not a defer.
  • Overdue appears if a Mac somehow gets past the deadline (e.g. was off/asleep at enforcement time). It warns that a restart may happen at any moment.

Not interrupting meetings (call/presentation suppression)

On early days (day <= suppress-until-day), before showing anything the script checks whether the user is busy — in a video call, screen sharing, or presenting. It detects this via a held display-sleep power assertion (pmset -g assertions, the PreventUserIdleDisplaySleep flag). This is a documented Apple mechanism, not a fragile private file.

If the user is busy, the script simply exits, and the next hourly run tries again — so it never interrupts a meeting and never sits polling in the background. Because the daemon already runs every hour, that next attempt comes soon after, typically right after the meeting ends.

On later days, and always on the enforcement day, it skips this check and shows immediately — the deadline is too close to keep deferring.

The daily anchor and snooze (why hourly doesn't mean hourly popups)

Two mechanisms keep the hourly daemon from nagging:

  • Daily anchor (daily-start, default 10:00): before this time, with no active snooze, the script stays silent. So nobody gets hit at 8am.
  • Once per day: after any dialog is shown, a marker records the date. With no active snooze, no further dialog appears until the next day — regardless of which button (or none) closed it. One nudge per day, per tier, including the enforcement day.
  • Snooze (state file): when someone defers (a typed time or "Tomorrow Morning"), the script writes a timestamp. Every hourly run checks it and stays quiet until that time passes, then nudges again and clears it.

Both are bypassed on the enforcement day — once the deadline arrives, snooze no longer applies and the urgent dialog shows regardless.

Showing the dialog in the user's session

Because the daemon runs as root with no GUI session, it can't launch swiftDialog directly (that throws RBSRequestErrorDomain ... Could not find specified domain). The script runs the dialog via launchctl asuser <UID> in the logged-in user's session. If nobody is logged in (login window / setup), it logs that and exits cleanly — the DDM deadline still applies regardless.

Logging

Everything goes to a rolling log at /Library/Management/updateNudge/updateNudge.log (auto-trimmed at ~1 MB), and also to stdout. Each run records the script version, what update was found, the SOFA result, the computed day count, and which button the user pressed.


What you need to configure

Settings are managed via a Configuration Profile (preference domain shoes.alec.updateNudge, keys: CompanyName, EnforceDay, EnforceTime, DailyStart, MaxMajorVersion, SuppressUntilDay, CheckOncePerDay, AutoDetectEnforcement) — the script re-reads it on every run, so changes apply within the hour with no reload. For local testing, the same settings exist as CLI flags (which take precedence). Each is already filled in with its default; edit the value and re-push the plist. You never need to edit update-nudge.sh itself.

Flag Default What it controls Must match DDM?
--company-name=NAME IT Org name shown in the dialogs (subtitle + "requires updates within N days") No
--enforce-day=N 10 Days after release when macOS force-installs. Fallback only while autodetect finds an active declaration Fallback — DDM "days after release to enforce"
--enforce-time=HH:MM 18:15 Local time of the forced install/restart. Fallback only while autodetect finds an active declaration Fallback — DDM "install at"
--auto-detect-enforcement=BOOL on Read the real deadline from the Mac's active DDM declaration; fall back to the two values above when none is readable No
--daily-start=HH:MM 10:00 Earliest time of day a nudge may appear No
--max-major-version=N 26 Ignore macOS majors above this Yes — DDM "Ignore major versions"
--suppress-until-day=N 4 Skip nudge during calls/presentations while day ≤ N No
--check-once-per-day off Opt-in: after a clean scan (no pending update), skip further scans for the rest of the day. Daemon keeps running hourly; a pending update restores normal behaviour No
StartInterval (profile key only) 3600 Seconds between daemon runs (min 300). Script self-heals the daemon plist and reloads itself on change No

⚠️ The three "should match DDM" settings

enforce-day, enforce-time, and max-major-version exist only to make the nudge's wording accurate. They do not control the actual enforcement — your DDM Software Update Enforcement declaration does.

With AutoDetectEnforcement on (the default), enforce-day and enforce-time are read from the Mac's own active DDM declaration whenever one is present, so day-to-day drift takes care of itself. Keep the configured values roughly in sync anyway: they are the fallback used in the window before a new release's declaration reaches the Mac, and on any Mac where the declaration can't be read. max-major-version is not part of the declaration data, so it still needs a manual update together with the DDM "Ignore major versions" setting (e.g. when rolling the fleet to macOS 27).

Changing the run schedule

Set the StartInterval key (seconds, minimum 300) in the same Configuration Profile. It's a launchd key, so it can't apply directly — instead the script detects the difference on its next run, rewrites its own daemon plist, and reloads the daemon via a detached helper. The change lands within one run cycle, no manual reload. RunAtLoad fires a fresh run right after the self-reload, so no nudge is lost.


Deploying it

Prerequisites on each Mac

  • swiftDialog installed at /usr/local/bin/dialog (Installomator label swiftdialog, or the pkg from the swiftDialog GitHub). The nudge scope and the swiftDialog scope should match, or the dialog call fails on any Mac missing it.
  • jq — present natively at /usr/bin/jq on macOS Sequoia (15) and later, so the current Tahoe fleet already has it. The script auto-detects jq in /usr/local/bin, /opt/homebrew/bin, /usr/bin, and PATH. Only Macs on Sonoma or earlier would need it installed.
  • DDM Software Update Enforcement configured — this tool assumes DDM is doing the real enforcement.

Install

Push install-update-nudge.sh via Jamf as a Script policy (trigger: Enrolment Complete + Recurring Check-in, once per computer). It writes the script and a default plist, sets permissions, and loads the daemon.

To configure, push your tuned shoes.alec.updateNudge.plist to /Library/LaunchDaemons/ afterward and reload:

sudo launchctl bootout system/shoes.alec.updateNudge
sudo launchctl bootstrap system /Library/LaunchDaemons/shoes.alec.updateNudge.plist

Verify

# Is the daemon loaded?
sudo launchctl print system/shoes.alec.updateNudge

# What did the last runs do?
tail -n 50 /Library/Management/updateNudge/updateNudge.log

Uninstall

sudo /Library/Management/updateNudge/install-update-nudge.sh uninstall

(or run the installer with the uninstall argument). This unloads the daemon and removes all files.


Testing

Run the script directly (it works standalone, outside the daemon):

# Print real diagnostics — no dialog. Best first check.
sudo /Library/Management/updateNudge/update-nudge.sh --check-only

# See the calm dialog (bypasses scheduling gate and busy-wait)
sudo /Library/Management/updateNudge/update-nudge.sh --dry-run --dry-run-day=5

# See the urgent (enforcement-day) dialog
sudo /Library/Management/updateNudge/update-nudge.sh --dry-run --dry-run-day=10

# See the overdue dialog
sudo /Library/Management/updateNudge/update-nudge.sh --dry-run --dry-run-day=12

# Test the SOFA lookup against a specific version without a real pending update
sudo /Library/Management/updateNudge/update-nudge.sh --check-only --fake-version=26.5.2

# List recent macOS versions from SOFA (handy for --fake-version)
sudo /Library/Management/updateNudge/update-nudge.sh --list-recent-versions

Test checklist before going fleet-wide

  • --check-only reports the correct pending version and a sensible day count
  • --check-only while in a Teams/Zoom call shows User busy … yes, and no when not in a call (confirms pmset detection on your macOS version)
  • Each --dry-run --dry-run-day= tier renders correctly
  • Installer loads the daemon (launchctl print shows it)
  • A real defer ("In 2 hours") writes the snooze and the next run stays quiet
  • Nothing appears before daily-start

Testing/diagnostic flags (not for the plist)

These are for manual testing only — don't put them in the daemon plist:

Flag Purpose
--check-only Print real diagnostics, show no dialog
--dry-run Simulate a pending update; bypass scheduling gate + busy-wait
--dry-run-day=N Force the day count (pairs with --dry-run)
--dry-run-open Also open the Software Update pane in dry-run
--fake-version=X.Y.Z Bypass softwareupdate -l, run the real SOFA lookup against this version
--list-recent-versions Print recent macOS versions from SOFA
--ignore-version-cap Preview a dialog for a version above max-major-version

Known caveats

  • pmset busy-detection should be confirmed on your actual macOS version via the in-call --check-only test above. It uses a documented mechanism, but worth verifying once.
  • The two-place version cap. --max-major-version and the DDM "Ignore major versions" checkbox are independent. Update both together.
  • swiftDialog dependency. If a scoped Mac lacks swiftDialog, the dialog call fails. Keep the scopes aligned.

About

A lightweight swiftDialog companion for macOS DDM Software Update enforcement — escalating reminders, meeting-aware suppression, and a user-set snooze, so nobody gets surprised by a forced restart.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages