Building a declarative ESP framework for macOS with SwiftDialog
Windows admins get an Enrollment Status Page for free. On macOS you build your own — and most homegrown onboarding scripts grow the same way: a bash file that starts at 80 lines, hits 800 within a year, and encodes the app list, the UI, and the install logic in one tangle. Changing what a fleet installs means editing the script.
The fix is the same one DDM brought to configuration: declare the desired state, let an engine converge on it. This post walks through the framework I now use — a JSON manifest describing the onboarding, a generic runner script that reads it, SwiftDialog as the progress UI, and Installomator doing the installs.
The manifest
One JSON file per fleet describes everything the onboarding does. No logic, just state:
{
"title": "Setting up your Mac",
"message": "Grab a coffee — this takes about ten minutes.",
"steps": [
{ "label": "Microsoft Edge", "installomator": "microsoftedge" },
{ "label": "Company Portal", "installomator": "microsoftcompanyportal" },
{ "label": "Slack", "installomator": "slack" },
{ "label": "Munki tooling", "installomator": "munkitools" },
{ "label": "Security baseline", "script": "/usr/local/onboarding/baseline.sh" },
{ "label": "Registering with Entra ID", "wait_for": "psso" }
]
}
Three step types cover everything I have needed so far:
installomator— a label passed straight to Installomator.script— a local script path, for the things that are genuinely custom.wait_for— a named readiness check (Platform SSO registration, MDM profile presence, a file landing). The step blocks until the check passes.
Changing a fleet’s onboarding is now a PR against a JSON file. The runner never changes.
The runner
SwiftDialog reads its own JSON on stdin and — the part that makes this architecture work — accepts live updates through a command file. Start the dialog once, then append commands as steps complete:
DIALOG_CMD="/var/tmp/dialog.log"
dialog --jsonfile /var/tmp/esp.json \
--commandfile "$DIALOG_CMD" \
--blurscreen --big --ontop &
# Per completed step:
echo "listitem: title: Microsoft Edge, status: success" >> "$DIALOG_CMD"
The runner is ~150 lines and generic: parse the manifest with plutil or jq, render the step list into SwiftDialog’s listitem format, iterate. Per step it dispatches on type:
run_step() {
local type label
label=$(step_value "$1" label)
echo "listitem: title: ${label}, status: wait" >> "$DIALOG_CMD"
if step_has "$1" installomator; then
/usr/local/Installomator/Installomator.sh \
"$(step_value "$1" installomator)" NOTIFY=silent
elif step_has "$1" script; then
"$(step_value "$1" script)"
elif step_has "$1" wait_for; then
wait_for_check "$(step_value "$1" wait_for)"
fi
if [ $? -eq 0 ]; then
echo "listitem: title: ${label}, status: success" >> "$DIALOG_CMD"
else
echo "listitem: title: ${label}, status: fail" >> "$DIALOG_CMD"
FAILURES=$((FAILURES + 1))
fi
}
Failures mark the step red and continue — a failed Slack install should not brick onboarding. The summary screen at the end reports anything that failed, and the runner exits non-zero so the MDM marks the policy for retry.
Readiness checks, not sleeps
The wait_for step type exists because onboarding scripts are full of sleep 30 calls that are either too short or too long. Every wait in the framework is a poll against an observable condition:
wait_for_check() {
case "$1" in
psso)
until app-sso platform -s 2>/dev/null \
| grep -q '"registrationCompleted" : true'; do
sleep 5
done ;;
mdm_profile)
until profiles list -type configuration 2>/dev/null \
| grep -q "com.example.baseline"; do
sleep 5
done ;;
esac
}
The psso check is the one that pairs with Platform SSO registration during onboarding — the dialog holds on “Registering with Entra ID” until the Secure Enclave key is provisioned, so no device leaves the ESP half-registered.
Triggering at enrollment
The framework ships as a package: manifest, runner, and a LaunchDaemon that fires once at enrollment. The daemon waits for a console user (Setup Assistant’s _mbsetupuser does not count), runs the runner, then disables itself:
<key>ProgramArguments</key>
<array>
<string>/usr/local/onboarding/runner.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
Intune deploys the package as a required app with Company Portal and Installomator ahead of it in the dependency chain. Total moving parts on the device: one daemon, one runner, one manifest.
Why “declarative” is the point
The win is not the progress bar — SwiftDialog makes any script look good. The win is that the onboarding definition is data:
- The manifest is diffable and reviewable. “Why does marketing get Figma?” has a git blame answer.
- The same runner serves every fleet; fleets differ only in manifest.
- Testing a change means running the runner against a manifest in a VM, not shipping a script edit to a pilot group and hoping.
- When DDM eventually grows first-party app-install status reporting, the manifest maps onto it and the runner shrinks.
The pattern is the same one that makes DDM better than imperative MDM commands: stop scripting the how, declare the what, and put the what under version control.