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.
| 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.
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).
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.
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.
On each run the script:
- Runs
softwareupdate -land 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). - Parses the exact version (e.g.
26.5.2) from that line. - 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. - Computes days since release and compares it to
enforce-day.
Urgency scales with how close the deadline is:
| Days since release | Tier | Look | Buttons |
|---|---|---|---|
0 … enforce-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.
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.
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.
Two mechanisms keep the hourly daemon from nagging:
- Daily anchor (
daily-start, default10: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.
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.
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.
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.
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 |
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.
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.
- swiftDialog installed at
/usr/local/bin/dialog(Installomator labelswiftdialog, 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/jqon 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, andPATH. Only Macs on Sonoma or earlier would need it installed. - DDM Software Update Enforcement configured — this tool assumes DDM is doing the real enforcement.
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# Is the daemon loaded?
sudo launchctl print system/shoes.alec.updateNudge
# What did the last runs do?
tail -n 50 /Library/Management/updateNudge/updateNudge.logsudo /Library/Management/updateNudge/install-update-nudge.sh uninstall(or run the installer with the uninstall argument). This unloads the daemon and
removes all files.
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-
--check-onlyreports the correct pending version and a sensible day count -
--check-onlywhile in a Teams/Zoom call showsUser busy … yes, andnowhen not in a call (confirmspmsetdetection on your macOS version) - Each
--dry-run --dry-run-day=tier renders correctly - Installer loads the daemon (
launchctl printshows it) - A real defer ("In 2 hours") writes the snooze and the next run stays quiet
- Nothing appears before
daily-start
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 |
pmsetbusy-detection should be confirmed on your actual macOS version via the in-call--check-onlytest 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-versionkeeps 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.