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.
jamf-extension-attribute.sh Jamf Pro (Extension Attribute) Optional: reads the per-run status.plist at inventory check-in for fleet-wide visibility. See "Fleet reporting" below.

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), and it decides as cheaply as possible: a run that already knows it can't show anything — a dialog was already shown today, a snooze is still in the future, or it's before daily-start — exits before performing any softwareupdate -l scan, in milliseconds and with no network.

When an update is pending, the day's first eligible run scans once and caches the result (pending_cache.txt); every later run that day — a snooze firing, a retry after a meeting — reuses it instead of rescanning. Staleness is checked locally for free: if the Mac's OS version has caught up with the cached pending version, the user installed it, and the cache is discarded. So each Mac performs at most one scan per day while an update is pending. A new Apple release later the same day is picked up the next morning, which the deadline math absorbs (see the once-per-day mode's trade-off note below — the same reasoning applies).

Opt-in: checking once per day even when nothing is pending

The pending-update path above already caps scanning at once per day. The remaining case is the clean day: with nothing pending, each run's scan is the only way to learn whether something new appeared, so by default those still happen every run. With CheckOncePerDay set to true (profile key, or --check-once-per-day), a scan that comes back clean is recorded too, and every later run that day exits immediately — making it one scan per day, pending or not. The day's single scan waits for the daily-start anchor, so it runs at the first run at/after the moment the first nudge could actually appear.

The daemon itself keeps its frequent heartbeat, deliberately — that's what makes typed snooze times fire, lets busy-retries work, and keeps deadline days resilient. Tiers, snooze, and call suppression are unaffected by this setting.

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 nudging into a full disk (storage check)

Before showing the install nudge, the script compares the space the update roughly needs (community-convention floors: ~15 GB minor, ~35 GB major upgrade, configurable) against what the installer can actually use — measured including APFS purgeable space (df understates by ignoring it). If there isn't enough, telling the user to install is pointless, and worse: DDM enforcement itself fails quietly on a full disk, silently producing overdue stragglers. So instead the user gets a "free up space" dialog (deadline still shown, one button opens Storage settings), and the status file reports insufficient_storage — so the Jamf Extension Attribute surfaces at-risk Macs days before the deadline. A failed measurement skips the check rather than blocking the nudge.

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.

Fleet reporting (status file + Jamf Extension Attribute)

Every normal run also rewrites a small local /Library/Management/updateNudge/status.plist (atomic write): outcome (nudged / no_update / idle / deferred / scan_failed / suppressed / insufficient_storage / no_user_session), pending version, day count, enforcement source (DDM vs configured), snooze state, and which button the user pressed. Gated runs that exit early just refresh the timestamp/outcome so inventory shows the daemon is alive without losing the day's richer fields. Diagnostic runs (--check-only, --dry-run, --fake-version) never write it.

Add jamf-extension-attribute.sh as a Jamf Extension Attribute (Settings > Computer Management > Extension Attributes, data type String, input type Script) and every inventory check-in reports a one-liner like:

v2.10.0 | 2026-07-10 15:02 | nudged (calm tier, user deferred) | pending 26.5.2 day 4, 6 left | src ddm | snooze until 15:30

Smart groups can then match substrings: LIKE "overdue" (past deadline), LIKE "scan_failed" (couldn't reach Apple), LIKE "insufficient_storage" (no room to install), LIKE "no_user_session" (pending update, nobody logged in), NOT LIKE "v2.10" (old script version). Disable with WriteStatusFile=false if you don't want the file written.


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, WriteStatusFile, MinSpaceMinorGB, MinSpaceMajorGB, Use12HourTime) — 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. Overridden by the active declaration's target major when autodetect finds one Fallback — 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
--write-status-file=BOOL on Write a per-run status.plist for the Jamf Extension Attribute (fleet reporting) No
--min-space-minor-gb=N 15 Free space (GB, incl. purgeable) required before nudging for a minor update; below it the nudge becomes a "free up space" dialog. 0 disables No
--min-space-major-gb=N 35 Same, for a major upgrade (pending major > running major) No
--use-12-hour-time off Show dialog times as 12-hour AM/PM ("6:15 PM"). Typed AM/PM defer times are accepted in either mode; logs and the status file stay 24-hour 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), all three are derived from the Mac's own active DDM declaration whenever one is present: enforce-day and enforce-time come from the declaration's deadline, and the major-version cap becomes the declaration's target major — so when you push a macOS 27.1 enforcement through your MDM, the nudge starts warning about it automatically, configured cap notwithstanding. If the scan lists several macOS updates at once (a 26.x minor alongside the 27.x upgrade), the declaration's target version is preferred — it's the update DDM will actually enforce.

Keep the configured values roughly in sync anyway: they are the fallback for whenever no declaration is readable. For max-major-version that fallback is genuinely load-bearing — when a new major ships and your fleet is fully patched, there may be no active declaration at all, and only the configured cap keeps the nudge quiet about an upgrade DDM isn't enforcing.

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 version cap fallback. While a Mac carries an active DDM declaration, the cap follows the declaration's major automatically. But in the window where a new major is out and no declaration is active (fleet fully patched), only the configured --max-major-version keeps the nudge quiet — so still keep it roughly aligned with the DDM "Ignore major versions" intent.
  • The DDM state file is undocumented. Autodetection reads /private/var/db/softwareupdate/SoftwareUpdateDDMStatePersistence.plist, which Apple could reshape in a future macOS. If parsing ever fails, the script falls back to the configured values and says so in the log — degraded accuracy, never silence.
  • 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

5 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages