A config-driven toolkit for automating macOS onboarding - rich progress UI via Swiftdialog's inspect mode and designed to deploy via Microsoft Intune.
One JSON file describes your entire Mac onboarding - apps, system config, and the progress UI the user watches. Two shell scripts do the rest. No MDM policies to sequence, no per-app installer scripts to write.
- Declarative: everything lives in
apps.json- prerequisites, apps, groups, detection rules, pre/post commands. Edit it with the bundled visual builder (a single HTML file, no install). - Self-contained engine: downloads and installs PKG/DMG/ZIP/TAR/7z/RAR itself - including awkward payloads like installers nested inside archives - with multi-URL fallback, retries, and aria2c acceleration.
- Idempotent: layered per-item detection means re-runs only install what's missing or new - which is also how config updates roll out to an existing fleet, silently.
- Live progress UI via SwiftDialog inspect mode, driven by real verified state.
- Secure by option: sign your config and devices refuse to execute anything you didn't sign.
Get started: host three files on any HTTPS storage, point the installer script at them, deploy via Intune - the whole path is 9 numbered steps.
- macOS Onboarding Toolkit (OTK)
- TL;DR
- Table of contents
- Features
- What makes OTK different?
- Quick start
- The script side
otk-install.sh- installer & updaterotk-intune-onboarding.sh- slim Intune entry pointonboardingProcess.sh- orchestratorapps.json- declarative config- Detection logic & defaults
- Multi-URL downloads & retries
- Bundled aria2c build for Apple Silicon
- Built-in custom commands
test_app_commands.sh- config test harness- Versioning & auto-updates
- The JSON Builder
- Internals
- Logs & troubleshooting
- Uninstall
- Config signing
- Security notes
- Acknowledgements
- License
- Config-driven installs using a single
apps.json(prereqs, apps, configs, and groups). - Detection-first. runs via
detection_commandsskip already-compliant items. - Robust downloads. Multiple URLs per item, retry/backoff,
aria2c→curlfallback - including a precompiled aria2c pkg for Apple Silicon since no official binary exists. - Pre/Post actions. Kill conflicting apps; run
root:oruser:commands before and after install. - UI or headless. Rich SwiftDialog progress UI in Inspect Mode, or
--silentfor unattended updates. - Dry-run mode. Probe URLs and execute detection without making any system changes.
- Versioning & auto-updates. Reads
global_settings.version, stores deployed version, and (optionally) installs a LaunchDaemon that re-applies onboarding silently when the config changes. - Signed config. Optional but recommended: RSA-signed
apps.json/toolkit downloads, verified on-device before anything runs as root - see Config signing. - Visual builder. A single-file HTML app (
json-builder/macos-deployment-builder.html) edits, validates, and exportsapps.jsonend-to-end. - Structured logging & state. Logs and per-item state plists under
/Library/Application Support/Microsoft/IntuneScripts/.
The Mac admin community has several excellent SwiftDialog-based onboarding tools, and most share the same shape: a script drives the progress UI while the actual installs are delegated elsewhere - to MDM-required apps, policies, or a separate installer tool - and "detection" means waiting for an .app bundle to appear. OTK was built because I needed more than that in three specific areas:
OTK doesn't delegate installs - it downloads and installs everything itself. Each item gets multi-URL fallback (;-separated, vendor link first, internal mirror second), retry with backoff, accelerated downloads via aria2c, and handlers for PKG, DMG, ZIP, TAR, 7z, and RAR - including nested payloads (a pkg inside a dmg inside a zip) and files served with misleading extensions (detect_package_type sniffs the real format).
The case that forced this: Logi Options+. Logitech ships a zip containing neither an app nor a pkg but a raw installer executable that must be run with vendor-specific silent flags. OTK extracts the archive, hunts for a payload in priority order (PKG → DMG → .app → executable), pinpoints the right file via installer_name, and exports it as $INSTALLER_PATH for your post-install commands:
The same machinery covers licensed installers that need tokens on the command line (via custom_variables) and any vendor executable with arbitrary switches - uniformly for every item, described in JSON rather than per-app script code.
Whether an item runs at all is decided by real detection, not just an app-bundle check: explicit detection_commands running as root or the console user, combinable with AND/OR logic (detect_all), then per-item state plists, then the /Applications/<name>.app fallback. That makes runs fully idempotent - re-running onboarding (or letting the update daemon force a pass after a config bump) skips everything already compliant, and works just as well for non-app items like wallpapers, default-handler settings, and device renaming as it does for installs. The same state plists drive the SwiftDialog rows, so the UI reflects actual verified state rather than "the script got this far."
The entire deployment is one apps.json - no variables to edit inside scripts. And you don't have to hand-write it: the bundled JSON Builder is a single-file HTML app (open it in a browser, no install) with drag-and-drop ordering, groups, helper-template dropdowns for the built-in commands, validation, and clean round-tripping of existing configs.
Everything below is explained in depth later; this is the minimum path from zero to an onboarded test Mac via Intune.
- Get the toolkit - clone or download this repo.
- Build your config - open
json-builder/macos-deployment-builder.htmlin a browser, click Load Example Data (or Load and pasteapps.example.json), edit your apps, then Download asapps.json. - Zip the runtime - from the repo root:
zip -r onboardingtoolkit.zip onboardingProcess.sh functions/(the two must sit at the zip root, no wrapping folder). - Zip your icons - every PNG your config references, in one flat
icons.zip. - Upload all three (
apps.json,onboardingtoolkit.zip,icons.zip) to any HTTPS host your Macs can reach (Azure Blob, S3, a web server) and note the three URLs. - Point the installer at them - edit the three
ONBOARDING_*_URLvalues at the top ofotk-intune-onboarding.sh. → details - Sign your config (recommended - one command) -
./otk-sign.sh --initcreates keys and wires the public key into the installer automatically; then./otk-sign.shand upload the two.sigfiles next to the artifacts. (Skipping this works too - devices just log a warning.) → details - Handle PPPC - if your onboarding uses AppleScript steps (wallpaper, default browser), deploy a PPPC profile (
pppc-example.mobileconfigis ready to adapt) and set its name in the script; otherwise empty the two PPPC constants to skip the gate. → details - Deploy via Intune - upload
otk-intune-onboarding.shas a macOS shell script (run as root, no arguments), assign it with a run frequency (e.g. daily), and enroll a test Mac. Watch progress in the SwiftDialog UI or in/Library/Application Support/Microsoft/IntuneScripts/logs/.
Config updates later are even shorter: edit apps.json → bump global_settings.version → re-sign → upload. The fleet picks it up on the next scheduled run. → details
Before any Mac can run the toolkit, three artifacts need to live at HTTPS URLs the target Mac can reach. Any static-file host works - Azure Blob Storage, S3, an internal CDN, even a private GitHub release.
1. Bundle the runtime - zip onboardingProcess.sh together with the functions/ folder so the layout inside the archive is:
onboardingtoolkit.zip
├── onboardingProcess.sh
└── functions/
├── custom-commands.sh
├── logging-configuration.sh
├── state-managment.sh
├── swift-dialog.sh
├── unified_installer.sh
└── utility-functions.sh
The installer extracts this directly into /Library/Application Support/Microsoft/IntuneScripts/onBoarding/, so the root of the zip must contain onboardingProcess.sh (not a wrapping folder).
2. Bundle your icons - collect every PNG referenced by apps.json (the icon field on items, prerequisites, and swift_dialog_settings) into a flat zip:
At install time the contents land in Swift Dialog/icons/ and SwiftDialog references them by filename. Icons are non-critical - a missing icons.zip will only warn, not fail the run.
3. Author your apps.json - fastest path is to open json-builder/macos-deployment-builder.html in a browser, click Load Example Data, edit, then Download. The exported file is the exact shape the orchestrator consumes. Alternatively, start from apps.example.json in this repo - a validated, fully-commented-by-structure example showing prerequisites, multi-URL fallbacks, detection, custom_variables, groups, and the built-in custom commands (replace every YOUR_HOST placeholder).
4. Upload all three to the same host. They don't have to share a folder, but the URLs need to be reachable over HTTPS from the Mac during onboarding.
5. Point the installer at your URLs. Edit the DEPLOYMENT CONFIG - REPLACE BEFORE DEPLOYING banner near the top of otk-install.sh and otk-intune-onboarding.sh:
readonly ONBOARDING_SCRIPTS_URL="https://<YOUR_HOST>/onboardingtoolkit.zip"
readonly ONBOARDING_APPS_URL="https://<YOUR_HOST>/apps.json"
readonly ONBOARDING_APPS_ICONS_URL="https://<YOUR_HOST>/icons.zip"apps.json is re-fetched by the LaunchDaemon's version check (when otk-install.sh is used), so updating just that file at the hosted URL - without changing the installer - is the supported way to push config updates to an installed fleet. Bump global_settings.version in the new apps.json so the daemon detects the change.
6. (Strongly recommended) Sign your artifacts. apps.json and onboardingtoolkit.zip are executed as root on every Mac in your fleet - signing them means a compromised storage account can't push malicious config to your devices. One command to set up, one command per update - see Config signing.
The toolkit ships two entry points. Both bootstrap onboarding from the same blob storage, but they differ in lifecycle:
| Script | LaunchDaemon | Auto-updates via apps.json version |
Best for |
|---|---|---|---|
otk-install.sh |
✅ creates one | ✅ daemon polls, re-downloads, and re-runs onboarding | Long-lived fleets where you want config bumps to roll out automatically without MDM involvement |
otk-intune-onboarding.sh |
❌ no daemon | ✅ Intune-driven: each scheduled run probes the hosted version and silently updates onboarded machines only when it's newer | Intune-managed fleets - assign with a run frequency and Intune becomes the update scheduler |
Upload either script as an Intune Shell Script and deploy without arguments. otk-install.sh defaults to --install, downloads resources, validates, runs the initial onboarding, and registers a daemon for ongoing version checks. otk-intune-onboarding.sh does the same one-shot work but skips the daemon - pair it with Intune redeploys when apps.json changes.
# 1) Run the installer (defaults to --install when no args are supplied)
sudo ./otk-install.sh
# 2) Or be explicit
sudo ./otk-install.sh --install
# 3) Re-run onboarding later (--force clears the completion flag)
sudo ./otk-install.sh --run
# 4) Show current status
sudo ./otk-install.sh --statusMake sure you've replaced the URLs in the
DEPLOYMENT CONFIGbanner first - see Prepare your hosting above.
/Library/Application Support/Microsoft/IntuneScripts/
├── onBoarding/
│ ├── onboardingProcess.sh
│ ├── apps.json
│ ├── functions/ # helper functions (sourced by onboardingProcess.sh)
│ └── onboarding.flag # written on successful completion; presence
│ # short-circuits re-runs (unless --force)
├── Swift Dialog/
│ └── icons/ # app icons for the dialog
├── logs/
│ ├── onboarding.log
│ ├── deployment.log
│ └── daemon.log / daemon-error.log # only if the LaunchDaemon is installed
└── state/
├── apps.version # stored configuration version
└── <id>.plist # per-item state (drives SwiftDialog row icons)
# Install / update toolkit (DEFAULT when no args are provided)
sudo ./otk-install.sh --install
# Show status
sudo ./otk-install.sh --status
# Run the onboarding process now (honors --dry-run)
sudo ./otk-install.sh --run [--dry-run]
# Validate installation
sudo ./otk-install.sh --validate
# Force download latest resources and optionally run onboarding
sudo ./otk-install.sh --force-update
# Create or remove the LaunchDaemon for periodic version checks
sudo ./otk-install.sh --create-launchd
sudo ./otk-install.sh --remove-launchd
# Check for updates without installing
sudo ./otk-install.sh --check-version
# Uninstall (prompts to keep or remove config/logs/state)
sudo ./otk-install.sh --uninstall
# Verbose debug logging (composable with other commands)
sudo ./otk-install.sh --debug --installA variant of otk-install.sh with the same bootstrap logic but no LaunchDaemon and a slim CLI - Intune owns the lifecycle instead.
sudo ./otk-intune-onboarding.sh # see behavior below - no args needed
sudo ./otk-intune-onboarding.sh --debug # verboseBehavior depends on the machine's state, so one Intune script assignment covers the whole fleet:
- Fresh machine - downloads the toolkit (signature-verified), installs it, runs onboarding once with the full SwiftDialog UI.
- Already onboarded (completion flag present) - acts as a version-aware updater: probes the hosted
apps.json(also signature-verified) and, only ifglobal_settings.versionis strictly newer than the installed version, re-downloads everything and re-runs the orchestrator with--force --silent- no UI, no reboot, and per-item detection means only new or changed items actually install. If the version isn't newer, it exits without touching anything. - Interrupted update - if a silent update dies mid-run (the completion flag is cleared during a forced run), the next cycle detects the machine has onboarded before and resumes silently instead of re-showing the onboarding UI.
Assign the script in Intune with a run frequency (e.g. daily or weekly) and the fleet converges on every config version bump - the same rollout model as otk-install.sh's LaunchDaemon, but with Intune as the scheduler.
Prefer the classic run-once-and-never-again behavior? Set ENABLE_FLEET_UPDATES="false" near the top of the script (Intune deploys without arguments, so the variable is the switch there; --run-once does the same for manual invocations). Onboarded machines are then never touched again and config updates reach new machines only.
Intune sometimes runs shell scripts before all device-targeted configuration profiles have landed. If onboarding starts in that window, AppleScript-driven steps (setting the wallpaper, auto-clicking the default-browser confirmation) hit unprovisioned TCC and hang on invisible consent dialogs or silently fail. To avoid this, otk-intune-onboarding.sh waits (default 10 minutes, override with the PPPC_WAIT_TIMEOUT env var) for your org's PPPC (Privacy Preferences Policy Control) profile to be present before doing anything, and exits non-zero on timeout so Intune retries later.
Two constants near the top of the script control the match - replace both with your own profile's values before deploying:
readonly PPPC_PROFILE_NAME_MATCH="PPPC Deploy" # substring of your profile's display name
readonly PPPC_PROFILE_IDENTIFIERS=( "..." ) # and/or explicit payload identifier(s)The check passes when either matches (name matching survives Intune's UUID rotation on profile edits). No PPPC profile in your org? Empty both ("" and ()) and the gate is skipped with a logged warning - just know that user-context AppleScript steps may then need TCC consent by other means.
otk-install.sh carries the same two constants for the same reason - they default to empty (gate skipped) there, which is right for manual and daemon deployments. If you deploy otk-install.sh through Intune instead of the slim script, configure them the same way.
Don't have a PPPC profile yet? Start from pppc-example.mobileconfig in this repo - it pre-approves exactly the TCC permissions OTK's AppleScript steps need (Accessibility + AppleEvents for the Intune MDM agent, /bin/bash, osascript, System Events, and Terminal for debug runs, toward Finder / System Events / System Settings / SystemUIServer). The header comment walks through the four steps: generate fresh UUIDs, set your org naming, upload as a Custom template profile in Intune assigned to the same device group as the script, and point PPPC_PROFILE_NAME_MATCH / PPPC_PROFILE_IDENTIFIERS at it.
# Standard run (with SwiftDialog UI)
sudo /Library/Application\ Support/Microsoft/IntuneScripts/onBoarding/onboardingProcess.sh
# Force re-run even if onboarding.flag exists
sudo /Library/.../onboardingProcess.sh --force
# Headless mode (no UI)
sudo /Library/.../onboardingProcess.sh --silent
# Dry run (probe URLs & run detection only; no system changes)
sudo /Library/.../onboardingProcess.sh --dry-run
# Debug logging
sudo /Library/.../onboardingProcess.sh --debug
# Help
sudo /Library/.../onboardingProcess.sh --helpReboot prompt: On successful completion in interactive mode, the SwiftDialog primary button triggers a reboot via
loginwindow.--silentand--dry-runskip this.
Top-level sections:
metadata- descriptive fields (version,created,organization,contact).global_settings- toolkit defaults:version,default_retries,default_dl_timeout,kill_apps_pre,kill_apps_post.swift_dialog_settings- dialog look & feel:title,message,iconsize,bannerimage, etc.prerequisites- items installed first, with no UI/state tracking (aria2c, SwiftDialog, utiluti, Rosetta… - jq itself is bootstrapped by the installer before apps.json can be parsed).items- array of items withtype: installation | config | group. Groups can nestapps.
Each item commonly supports:
id(required - used as the SwiftDialog row identifier and as the per-item state filenamestate/<id>.plist),type,name, optionalsubtitle,icon- Installers:
download_url(single, or;-separated list of fallback URLs tried in order),download_options(e.g.use-curlto force curl over aria2c), optionaldownload_path,installer_name,app_pathfor.appinside DMG/archive,archive_passwordfor password-protected zip/7z/rar payloads (builder: Advanced tab; ignored with a logged warning for PKG/DMG/tar payloads; noteapps.jsonis world-readable on the device, so use it for AV-evasion wrappers, not real secrets) - Behavior:
retries,timeout,detect_all(true= AND,false/missing = OR for multi-line detection),kill_apps,alt_app_name - Logic:
detection_commands(string or array; lines prefixed withroot:oruser:) - Post actions:
pre_install_commands,post_install_commands(root or user context) - Variables:
custom_variables(key/value map; values can be strings or arrays - referenced in commands as$KEY)
{
"metadata": {"version": "1.0", "organization": "Your Org"},
"global_settings": {"default_retries": 2, "default_dl_timeout": 300},
"swift_dialog_settings": {
"title": "macOS Onboarding",
"message": "Please wait while we configure your Mac..."
},
"items": [
{
"type": "installation",
"name": "Microsoft Edge",
"icon": "edge.png",
"download_url": "https://go.microsoft.com/fwlink/?linkid=2093504",
"retries": 3,
"detection_commands": [
"root:[ -d '/Applications/Microsoft Edge.app' ]"
],
"kill_apps": ["Safari"],
"post_install_commands": [
"user:set_browser_default \"Microsoft Edge\""
]
},
{
"type": "config",
"name": "Set Wallpaper",
"download_url": "https://your-host/corp-wallpaper.jpg",
"download_path": "/Users/Shared/corp/wall.jpg",
"detection_commands": [
"root:[ -f /Users/Shared/corp/wall.jpg ]"
],
"post_install_commands": [
"user:osascript -e 'tell app \"Finder\" to set desktop picture to POSIX file \"/Users/Shared/corp/wall.jpg\"'"
]
}
]
}The toolkit uses a layered strategy to decide whether an item is already compliant and can be skipped:
- Explicit detection commands (preferred). If
detection_commandsis set, each line runs in the specified context (root:oruser:). Combine checks with&&on a single line, or list multiple lines and choose:detect_all: true→ AND (all commands must succeed)detect_all: false→ OR (any command may succeed)
- State tracking. When an item installs successfully, the engine writes a per-item plist under
state/<id>.plistwithstate=installed. On subsequent runs, that plist is checked and the item is skipped. - Application bundle fallback. With no
detection_commandsand no state file, the engine checks/Applications/<name>.app(oralt_app_nameif set, e.g.TeamViewerHost.app).
Tip. Prefer explicit
detection_commandsfor non-app configurations (defaults, profiles, handlers, wallpapers) so detection stays reliable.
{
"type": "installation",
"name": "Microsoft 365 Apps",
"detection_commands": [
"root:[ -d '/Applications/Microsoft Word.app' ] && [ -d '/Applications/Microsoft Excel.app' ] && [ -d '/Applications/Microsoft PowerPoint.app' ] && [ -d '/Applications/Microsoft Outlook.app' ]"
]
}download_url accepts a ;-separated list. The toolkit tries each URL in order until one succeeds - useful for vendor-link fragility and internal mirrors.
{
"type": "installation",
"name": "Microsoft Edge",
"download_url": "https://go.microsoft.com/fwlink/?linkid=2093504;https://mirror.yourorg.local/macos/edge.dmg",
"retries": 3
}- Order matters. Vendor canonical link first, then internal mirror(s).
- Redirects supported. SharePoint and similar links route through
download_with_redirects. - Dry-run. All URLs are probed (HTTP HEAD) before any download is attempted.
- Retries. Per-item
retriesoverridesglobal_settings.default_retries. SwiftDialog rows only flip to "Failed" once retries are truly exhausted.
The toolkit prefers aria2c over curl for downloads - it opens up to 16 parallel connections per file (-x16 -s16), which makes a real difference during onboarding when a Mac is pulling down several multi-hundred-MB installers in a row.
The catch: the aria2 project doesn't publish a precompiled macOS binary for Apple Silicon, and during onboarding there's no Homebrew (or Xcode toolchain) to build it with. So I compiled aria2 1.37.0 natively for Apple Silicon ourselves and packaged it as a standard installer pkg - see utils/aria2-1.37.pkg in this repo. Host it alongside your other artifacts and install it as a prerequisite:
{
"id": "aria2c",
"type": "installation",
"name": "aria2c",
"download_url": "https://<YOUR_HOST>/aria2-1.37.pkg",
"detection_commands": "root:command -v aria2c"
}aria2c is strictly optional. If it isn't installed (or a download source misbehaves with segmented downloads), the toolkit automatically falls back to curl - and you can force curl per item with "download_options": "use-curl".
These helpers can be used in pre_install_commands / post_install_commands (prefix each line with root: or user:). All ship in functions/custom-commands.sh and are surfaced as helper-template dropdowns in the JSON Builder.
Device & defaults
install_rosetta2- Installs Rosetta 2 on Apple Silicon (no-ops on Intel). Safe to call unconditionally as a prerequisite.rename_device- Renames the Mac using a template likemac-{serialnum}or{prefix}-{serialnum}-{modelcode}. Supports{country}and{random[N]}tokens, and a--testonlymode that returns 1 if a rename would occur. Sets ComputerName, HostName, and LocalHostName.set_browser_default <app-name> [button-match]- Sets the default browser for any Chromium-based browser (Edge / Chrome / Brave / …). Opens the app with--make-default-browserand auto-clicks the OS confirmation dialog. Example:set_browser_default "Microsoft Edge".set_default_mail_client <bundle-id>- Sets the defaultmailto:/.ics/ vCard handler (viautilutiso needs to be installed as prerequisite).set_default_locale <locale>- SetsAppleLocaleplus the 24-hour clock flag at both system and per-user scope (e.g.set_default_locale en_GB).set_login_item <app-path> [--hidden true|false]/set_login_item --remove <app-path>- Adds or removes a user-scoped Login Item.
Dock management
configure_dock- Token-first Dock configuration:- Apps:
/Applications/Microsoft Edge.app - Spacers:
spacer,small-spacer,flex-spacer - Folders/Stacks:
dir-Downloads:created:stack:fan,dir-Applications:name:folder:grid - Options:
--append,--no-restart,--dry-run,--user <name>
- Apps:
User-facing dialogs
show_action_dialog --title <T> --message <BODY> [--timer <seconds>]- Prompts the console user with a SwiftDialog box that auto-closes after the timer expires. Useful for "toggle Screen Recording for these apps and click Done" steps.
Session & UX utilities (in functions/utility-functions.sh)
clear_all_notifications(JXA) - programmatically dismisses Notification Center banners.wait_for_process- waits (and optionally terminates) a process within a timeout.wait_for_desktop- waits for the Dock before continuing.get_current_user- resolves the effective console user robustly.kill_application- graceful then forceful terminate by process name.retry_with_backoff- generic retry helper with exponential backoff.generate_random_string- helper for templating/device naming.
{
"type": "config",
"name": "Set Edge as Default",
"post_install_commands": [
"user:set_browser_default \"Microsoft Edge\""
]
}You can pass arbitrary values via custom_variables and reference them as $NAME in commands.
Validate a config's commands before signing and shipping it - from any machine for a readable dry-run, or on a Mac to actually execute them:
./test_app_commands.sh [JSON_FILE] [MODE] [DRY_RUN]
./test_app_commands.sh # dry-run apps.example.json, all commands
./test_app_commands.sh apps.json all true # print every command your config would run
./test_app_commands.sh apps.json detection false # execute detection commands (on a Mac)- MODE:
all|detection|pre|post- which command lists to walk. - Covers
prerequisites[]anditems[](groups nested), accepts string and array command forms, substitutes each item'scustom_variables, and honorsroot:/user:context. Runtime-only variables like$INSTALLER_PATHare shown verbatim. - With
DRY_RUN=false,functions/is sourced when present, so built-in custom commands (rename_device --testonly ...,configure_dock --dry-run ...) are callable.
- Set your config version at
global_settings.versioninapps.json. - The installer writes the deployed version to
state/apps.version. - Two rollout mechanisms share the same version comparison; pick per fleet:
- LaunchDaemon (
otk-install.sh; opt-in via--create-launchd, automatically created by--install) periodically calls--daemon-checkand, if the upstreamapps.jsonversion is newer, re-downloads the full artifact set (apps.json,onboardingtoolkit.zip,icons.zip- so new items get their icons and script fixes roll out too) and re-runs onboarding as--silent --force. The polling cadence isVERSION_CHECK_INTERVAL_MINUTESnear the top of the script (default 60). - Intune-scheduled (
otk-intune-onboarding.shassigned with a run frequency) - each run on an onboarded machine probes the hosted version and applies the same--force --silentupdate only when it's newer.
- LaunchDaemon (
- The
--forceis essential in both - without it the completion flag would short-circuit the re-run and version bumps would silently fail to apply on already-onboarded machines. - Per-item detection still runs on every forced pass, so apps that are already installed are skipped -
--forceonly forces the orchestrator to evaluate items again, not to reinstall everything. - The comparison is strictly newer, major.minor only (
1.2 → 1.3triggers,1.2 → 1.2doesn't) - a content edit without a version bump will never roll out. Removing an item also doesn't uninstall it from machines that already have it; config is additive. - What a version bump updates:
apps.json,onboardingtoolkit.zip(orchestrator + allfunctions/logic), andicons.zip- so script fixes and new-item icons roll out with the config. What it doesn't: the installer script itself - the daemon runs a copy ofotk-install.shinstalled at setup time. How that copy gets updated depends on your deployment:- Deployed via Intune: nothing extra needed. Update the script in Intune; when Intune re-runs it,
--installre-copies the fresh script over the daemon copy and rewrites the LaunchDaemon plist (so interval changes propagate too). - No MDM (manual installs): either re-run
--installby hand, or include your configuredotk-install.sh(your URLs + public key) insideonboardingtoolkit.zip- the daemon's update pass then refreshes its own code on every version bump, effective the next tick. Plist changes (e.g.VERSION_CHECK_INTERVAL_MINUTES) still need a manual--create-launchd.
- Deployed via Intune: nothing extra needed. Update the script in Intune; when Intune re-runs it,
- Update-day checklist: edit
apps.json→ bumpglobal_settings.version→jq empty apps.json+./test_app_commands.sh apps.json all true→ re-ziponboardingtoolkit.zipif scripts changed → add any new item icons toicons.zip→./otk-sign.sh→ upload each file with its.sig(icons.zip needs no signature). - Mind
kill_appson items you expect to update after onboarding - silent update runs will kill those processes even if the user is mid-task.
The json-builder/macos-deployment-builder.html is a single-file visual editor for apps.json. No build step, no server, no install - just open it in a browser.
It exists because hand-editing nested JSON for dozens of apps with detection commands, kill lists, post-install actions, and group nesting is error-prone. The builder enforces the schema, provides command-template dropdowns for known helpers, and round-trips cleanly with the apps.json your installer consumes.

The builder canvas. Top: header and quick actions. Middle: prerequisite list, then your apps and groups (drag-and-drop reordering, nested drop zones). Right/bottom: live JSON preview with Copy / Download / Load buttons.
The page has three regions:
- Header & nav. Add Item, Add Group, Global Settings, Swift Dialog, Load Example Data, Clear.
- Lists. A pinned Prerequisites list (SwiftDialog is always there), then Applications & Configurations with drag-and-drop reordering and nested groups (drop items into a group's dashed drop zone).
- Generated Configuration. A live JSON pane with Copy, Download, and Load controls - paste an existing
apps.jsonto keep editing it.
Click an item to open the editor. It's organized into five tabs.
Basic and Advanced tabs: type (Installation / Config), name, icon, download URL, alt app name, retries, timeout, install paths.
- Basic - Prerequisite toggle, type (Installation / Config), name, icon, download URL (single, or
;-separated list of fallback URLs),download_options(e.g.use-curlto force curl over aria2c), alt app name. - Advanced - retries, timeout, download path, install path, installer name, app path inside DMG/archive.
Commands, Detection, and Variables tabs: pre/post-install command builders, kill apps, detection rules with AND/OR logic, custom variables.
- Commands - Pre-Install, Post-Install, and Kill Apps lists. Each list has a helper-template dropdown that injects ready-made calls to the built-in custom commands (
install_rosetta2,configure_dock,rename_device,set_browser_default,set_default_mail_client,set_default_locale,set_login_item,show_action_dialog, …) so you don't have to remember names or syntax. Detection/pre-install/post-install entries are validated to require aroot:oruser:prefix - adding a bare command surfaces an error snackbar instead of silently exporting it. - Detection - list of detection commands plus a logic toggle (OR / AND) that maps to
detect_all. Helper templates for common patterns are also provided. The toggle defaults to OR, matching the runtime's behavior whendetect_allis missing. - Variables - key/value pairs added to
custom_variables. Each row has a string / array type toggle. In array mode the value renders as a vertical list of inputs with+ Add itemand per-row remove buttons - no raw JSON to type. Switchingstring ↔ arrayconverts the value (newline-separated string becomes array entries and vice versa).
Quality-of-life: inline values throughout the modal are click-to-edit (visible border + focus outline). Saving an item warns via snackbar if the
idwas auto-generated, sinceidis the SwiftDialog row identifier and the state plist filename. Closing the tab with unsaved edits triggers the browser's "leave site?" prompt.
Org Info, Deployment Settings, and the global Kill Apps Before / After tabs.
The Global Settings modal exposes four tabs:
- Organization Info -
metadata.organization,contact,created, etc. - Deployment Settings -
global_settings.version,default_retries,default_dl_timeout. - Kill Apps Before - populates
global_settings.kill_apps_pre(apps killed once before any item runs). - Kill Apps After - populates
global_settings.kill_apps_post.
Dialog Content, Visual Elements, and Appearance & Behavior tabs.
Three tabs cover everything that ends up under swift_dialog_settings:
- Dialog Content - title, message, primary/secondary button text and behavior.
- Visual Elements - icon, banner image, banner title, accent and background colors.
- Appearance & Behavior - preset (
preset1–preset9), dark / light / auto, dimensions, blur, moveable, on-top, fullscreen.
- Copy - copy the JSON to clipboard.
- Download - save as
apps.json. - Load - paste or upload an existing
apps.jsonto continue editing.
The builder is round-trip safe: anything the orchestrator understands, the builder can re-parse and re-emit - including keys it has no editor controls for, which pass through untouched on load/export.
Advanced dialog keys (hand-edit only): a few inspect-mode power knobs have no builder controls:
cacheExtensions(file extensions the download detection watches; defaults to["download","pkg","dmg","aria2"]) and the visual keysbackgroundColor,backgroundImage,backgroundOpacity,textOverlayColor,gradientColors,colorThresholds(see the SwiftDialog inspect-mode docs). Add them toswift_dialog_settingsby hand if you need them - they round-trip safely through the builder.
How the installer & orchestrator are wired (click to expand)
- The installer's
--validatesubcommand runs syntax checks ononboardingProcess.sh, validatesapps.jsonwithjq, and ensures every item (top-level or nested) has a non-nullname,type, andid- a missingidwould produce an empty plist filename and silently break SwiftDialog inspect mode for that row. - At startup, the orchestrator validates that all
functions/*.shsource successfully (any syntax error aborts withEXIT_CONFIG_ERROR) and that core tools (jq,curl,dialog, etc.) are present via theprerequisites[]install pass.
probe_urlsquickly checks URL reachability (HEAD) and validates URL syntax.download_filesupports multiple URLs (semicolon-separated), follows redirects (SharePoint, etc.), and falls back fromaria2ctocurlas needed.validate_downloadconfirms a download exists and is non-empty.
-
process_applications/process_app/process_groupiterate items, drive SwiftDialog progress, honor detection state, and aggregate Installed/Skipped/Failed counts. -
install_applicationorchestrates an item's lifecycle: pre-install commands → download → install/prepare → post-install commands. -
download_itemresolves final download paths (with sane defaults in$TEMP_DIR) and returns the path for the next stage. -
detect_package_typeidentifies PKG/DMG/ZIP/TAR/7Z/RAR even when extensions mislead, and renames files to sane extensions when possible. -
Archive tooling: ZIP/TAR family extract with native macOS tools.
.7zprefers a7zz/7zbinary when installed and falls back to macOS's built-in bsdtar (libarchive), which reads standard 7z archives natively..rarprefersunrarwhen installed, falling back to bsdtar (RAR v4 and most v5); encrypted or exotic archives need a real tool. -
Password-protected archives: set
archive_passwordon the item. zip prefers7zz/7z(stockunzip -Ponly handles legacy ZipCrypto, not AES-encrypted zips) withunzip -Pas last resort; 7z/rar passwords require7zz/7z/unrar(bsdtar cannot decrypt) - install the Keka prerequisite below for those. -
Full 7z/RAR support via Keka: Keka (≥1.2.11) embeds
7zz,unrar, and friends behindKeka --cli. Install it as a prerequisite and expose the binaries with thin wrappers - the engine picks them up automatically:{ "id": "keka", "type": "installation", "name": "Keka", "download_url": "https://github.com/aonez/Keka/releases/download/v1.6.7/Keka-1.6.7.dmg", "detection_commands": "root:command -v 7zz && command -v unrar", "post_install_commands": [ "root:printf '#!/bin/bash\\nexec \"/Applications/Keka.app/Contents/MacOS/Keka\" --cli 7zz \"$@\"\\n' > /usr/local/bin/7zz && chmod 755 /usr/local/bin/7zz", "root:printf '#!/bin/bash\\nexec \"/Applications/Keka.app/Contents/MacOS/Keka\" --cli unrar \"$@\"\\n' > /usr/local/bin/unrar && chmod 755 /usr/local/bin/unrar" ] } -
Install handlers:
install_pkg_file- uses Apple'sinstallerfor.pkg/.mpkg.install_dmg_file- mounts headlessly, installs PKG inside, or copies a discovered.appbundle to/Applications.install_archive_file- extracts archives, then looks (in order) for PKG → DMG →.app→ executable scripts.
-
Command execution:
execute_commandsrunsroot:oruser:commands, supports variable substitution (includingcustom_variables), and exportsINSTALLER_PATHfor archive-provided installers. -
Kill / retry / backoff:
kill_applicationsandkill_applicationgracefully terminate conflicting processes.retry_application_installationandretry_with_backoffprovide resilient retries with exponential backoff.
wait_for_desktopwaits until the Dock is running before showing UI or applying user-scoped changes.check_enrollment_timinglets you enforce a run window (e.g., first hour after MDM enrollment) and writes a completion flag if outside the window.
- The orchestrator builds an Inspect Mode config from
apps.json(one row per top-level item, groups roll up to a single row driven by their group plist) and watches both the per-item state plists (FSEvents → green check) and[STATUS] …lines inlogs/onboarding.log(logMonitor → live status text). - The plist is the single source of truth for success.
- Installer logs:
.../logs/deployment.log - Onboarding logs:
.../logs/onboarding.log - Daemon logs:
.../logs/daemon.loganddaemon-error.log
If something fails:
- Re-run with
--debugfor verbose output. - Use
--dry-runto validate connectivity and detection logic safely. - Verify
download_urlvalues are reachable and that vendor PKGs/DMGs haven't moved. - Add conflicting processes to
kill_appsif installs hang.
sudo ./otk-install.sh --uninstallStops and removes the LaunchDaemon (if present) and removes the installation directory. You'll be prompted whether to also remove configuration, logs, and version state.
The commands in apps.json - and the scripts in onboardingtoolkit.zip - run as root on every device. That means whoever can write to your hosting URLs effectively has root on your whole fleet, and with otk-install.sh's LaunchDaemon the devices re-fetch and re-apply config silently and periodically. You can't filter "malicious commands" out of a system whose job is running arbitrary root commands - but you can make sure only config you signed ever runs. That's what otk-sign.sh provides: detached RSA/SHA-256 signatures, verified on-device before anything is parsed or executed.
./otk-sign.sh --initThis creates a keypair in ./signing/ and writes the public key into ONBOARDING_SIGNING_PUBKEY in both otk-install.sh and otk-intune-onboarding.sh automatically - no copy/paste, no transcription errors. (The variable sits right under the deployment URLs; re-apply it anytime with ./otk-sign.sh --install-key, e.g. after pulling fresh copies of the installers.) The installers reach the devices through Intune - your trusted channel - so the public key rides along with them.
⚠️ signing/otk-signing.keyis the crown jewel: never commit it, never upload it to your hosting. Back it up offline. Leaking it lets an attacker sign config for your fleet; losing it means generating a new pair and redeploying the installers.
./otk-sign.sh # signs apps.json + onboardingtoolkit.zip
# or explicitly:
./otk-sign.sh apps.json onboardingtoolkit.zipUpload each file together with its .sig to the same location (apps.json + apps.json.sig, etc.). That's the entire workflow - no per-device steps, and the daemon's auto-update path verifies exactly like the initial install does.
You can sanity-check a pair before uploading:
./otk-sign.sh --verify apps.jsonIf you manage the config from a Windows machine, otk-sign.ps1 is a byte-compatible PowerShell port - same key files, same detached .sig format (RSA PKCS#1 v1.5 / SHA-256), fully interchangeable with the bash tool and verified identically by the Macs:
.\otk-sign.ps1 -Init # once: keys + auto-writes the public key into both installers
.\otk-sign.ps1 # signs apps.json + onboardingtoolkit.zip
.\otk-sign.ps1 -Verify apps.json # pre-upload sanity check
.\otk-sign.ps1 -InstallKey # re-apply the public key to the installer scriptsRequires PowerShell 7+ (winget install Microsoft.PowerShell). Alternatively, run otk-sign.sh as-is from WSL or Git Bash - both ship openssl. Keys generated by either tool work with the other.
- The installer downloads the artifact, then fetches its
.sigand verifies with the pinned public key viaopenssl dgst -sha256 -verify- works with the LibreSSL that ships on macOS, nothing to install. By default the signature URL is derived as<url>.sig(from any mirror in a;-separated list), which suits plain static hosting where you control the path. - Hosting with unpredictable URLs: if your file service assigns opaque per-file URLs (
https://host/tEmpid1234- the uploaded.siggets its own unrelated ID) or your URLs carry query strings (SAS tokens, presigned links - appending.sigbreaks them), set the explicitONBOARDING_APPS_SIG_URL/ONBOARDING_SCRIPTS_SIG_URLvariables in the deployment config to the.sigfiles' actual URLs. They sit right under the public key and accept;-separated mirror lists too. - Fail-closed: with a public key configured, a missing or invalid signature aborts the run before anything is extracted, parsed, or executed. A stale
.sig(updatedapps.json, forgot to re-sign) fails the same way - the log message tells you which case you're likely in. - Opt-in: with
ONBOARDING_SIGNING_PUBKEYleft empty, verification is skipped with a logged warning, so first-time setups still work out of the box. icons.zipis intentionally not verified - it contains inert images and is already treated as non-critical.
Signing protects the config channel. The download_urls inside apps.json still fetch vendor installers unverified - pin those to versions you host yourself where that matters.
- Sign your config - see Config signing. It's the difference between "attacker compromised our storage account" being an incident and being a fleet-wide root compromise.
- Treat hosting locations and tokens as secrets; do not commit them to public repos.
- Prefer private artifact storage or short-lived signed URLs, and restrict write access to the hosting container to as few principals as possible - enable versioning/alerts on writes if your storage supports it.
- Limit post-install commands to expected operations and review any
root:actions carefully before signing a new config -./test_app_commands.sh apps.json all trueprints every command a config would run, which makes a good pre-publish review step. - The deployed files are readable by every local user.
apps.jsonlands on disk root-owned but world-readable, so any license tokens or IDs you put incustom_variablesare visible to anyone with an account on the Mac. Treat values inapps.jsonas fleet-visible, not secret. Reading is all a standard user can do, though: the installers force root-only ownership and strip group/world write bits after install, and reading a.sigor the public key doesn't help an attacker - signatures only validate the exact bytes that were signed, and forging one for modified content requires the private key.
- SwiftDialog - the onboarding progress UI (inspect mode).
- utiluti - default app & URL handler utility.
- jq - JSON processing.
- aria2 - download accelerator (optional). No official Apple Silicon binary exists, so this repo ships its own compiled pkg - see Bundled aria2c build for Apple Silicon.
- 7-Zip - optional; standard
.7zarchives extract via macOS's built-in bsdtar (libarchive), a7zz/7zbinary is only needed for exotic codecs or encrypted archives. - Keka - optional; a convenient prerequisite for full 7z/RAR support via its embedded
7zz/unrarbinaries (see the archive tooling note in Internals). - BeerCSS - UI framework for the JSON Builder.
- Material Symbols - icons in the JSON Builder.
- Sortable.js - drag-and-drop in the JSON Builder.
License MIT - see LICENSE.md





{ "type": "installation", "name": "Logi Options+", "alt_app_name": "logioptionsplus.app", "download_url": "https://download01.logi.com/web/ftp/pub/techsupport/optionsplus/logioptionsplus_installer.zip", "installer_name": "logioptionsplus_installer", "post_install_commands": [ "root:$INSTALLER_PATH --quiet --analytics No --flow No --sso No" ] }