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. |
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). 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.
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.
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.
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.
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 |
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).
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 two-place version cap.
--max-major-versionand 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.