Like PSAppDeployToolkit, but for macOS.
macADT (macAppDeployToolkit) is an Enterprise App Deployment framework for macOS, in the spirit of PSADT, compatible with Intune.
Intune can install a .pkg. However, if the app we want to patch is already opened, it will fail. This issue is very common with web browser patched over Intune on macOS devices.
macADT improves the deployment of other pkg files, by adding interactivity like:
- ability to warn the user that we will close its app;
- ability to allow the user to defer the installation;
- ability to show a countdown;
- ability to close automatically an app and then to update it.
macADT produces a complete pkg file, that contains everything: the swiftDialog, the app that adds interactivity, a manifest, and the third party app to deploy or patch. The third party can be deployed through a pkg and dmg.
You can choose the deployment mode (silent, non-interactive, interactive, like in PSADT), and even add any pre/post scripts.
- Centralized logging — a global
deploy.logand one per-app log, both under/var/log/MacADT/by default (override the base directory with theMACADT_LOG_DIRenvironment variable, or the global log file alone with--log), plus a unified log subsystem (com.macadt.deploy) and an inspectable state file;macadt-deploy status --jsonis the source of truth. - Closing running applications — macadt-deploy detects blocking apps itself (libproc, agent side) and closes them with
SIGKILL. No PPPC profile, no AppleEvent, no TCC authorization. - User-facing dialog — a warning dialog (swiftDialog) with deferrals, a countdown, an optional deadline, and persistence that brings an ignored dialog back to the foreground.
- Three deployment modes —
interactive,nonInteractive,silent, chosen per deployment; what separates them is visibility, not power. - Self-contained signed pkg — one
build-app-pkg.shproduces a signed, universal pkg that carries the agent, swiftDialog, the manifest and the payload. Xcode not required. - DMG wrapper — supports apps distributed as a DMG, as it will then create a PKG and deploy it through Intune's
macOSPkgApp, thus allowing to run pre and post scripts for DMG. - Per-app hooks —
pre/postinstall and uninstall scripts, confined to the manifest directory and run as root behind four safeguards. - Safe uninstall — the uninstall plan is a pure, auditable value with an allowlist-by-default path policy, built so a mistake cannot cause a fleet-wide wipe.
--dry-run— the full evaluation without root, without executing anything and without consuming a deferral.
On a Mac that has swiftDialog (to embed it) and a Developer ID Installer certificate, with a googlechrome.dmg file, run:
# 1. A manifest describes the application (see "The manifest").
# 2. Build the signed self-contained pkg + the post-install:
export MDT_TEAM_ID=XXXXXXXXXX
Scripts/build-app-pkg.sh -m chrome.json -p ~/Downloads/googlechrome.dmg --mode Interactive
# → .build/apps/<packageKey>/<packageKey>.pkg (the signed package)
# → .build/apps/<packageKey>/<packageKey>-postinstall.sh (to paste into Intune)In Intune ("macOS app (PKG)" type): upload the pkg, paste the postInstallScript, fill in Included apps (bundle id + version of the target app). No preInstallScript, no dependency to deploy.
{ "packageKey": "internal-tool" }| Field | Default | Role |
|---|---|---|
packageKey |
required | Stable identifier. Carries the deferral counter — changing it resets it. Never derived from displayName |
displayName / version / publisher |
— | — |
logName |
derived | Per-app log in /var/log/MacADT/ |
mode |
interactive |
interactive | silent | nonInteractive. Just a default — the deployment's --mode wins |
showDialogWhenNotRunning |
true |
Show the gate even when no blocking app is running (an interactive install notice). A default — the deployment's --show-when-not-running wins |
icon |
— | Dialog icon, passed to swiftDialog's --icon: a file path, an app bundle, an SF=symbol, a URL. Absent = no icon |
blockingApps[] |
[] |
bundleIdentifier and/or executableName, displayName. A plain string is accepted |
deferral.maxDeferrals |
3 |
Number of deferrals |
deferral.deferralDays |
— | Sliding window from the first prompt (-DeferDays) |
deferral.deadline |
— | ISO 8601 cutoff date (-DeferDeadline) |
deferral.countdownSeconds |
300 |
Dialog countdown, on install |
deferral.uninstallCountdownSeconds |
60 |
Same, on uninstall / repair |
deferral.persistPromptSeconds |
60 |
Re-display of an ignored dialog. 0 disables |
deferral.maxDialogSeconds |
1200 |
Budget of a deferrable dialog before abandoned |
deferral.forceCloseWhenExhausted |
true |
Force close (SIGKILL) once the window is exhausted |
requirements.respectPresentationMode |
true |
Do not disturb during a video call |
requirements.respectScreenLock |
true |
Do not close while the user is away |
detection |
— | bundlePath, expectedVersion, packageIdentifier |
uninstall |
— | packageIdentifiers, paths, launchdLabels, preservedPaths |
hooks |
— | preInstall, postInstall, preUninstall, postUninstall |
payload |
— | file (confined to the manifest directory), installLocation, bundleName |
ignoredExitCodes |
[] |
Hook codes treated as successes. Alias IgnoreExitCodes |
messages.strings |
built-in FR + EN | Partial override accepted |
restartAction |
— | none | recommendRestart | requireRestart | requireLogout | requireShutdown |
Tokens in the texts: {name}, {version}, {apps}, {deferrals}, {deadline}. An unknown token is left visible rather than replaced with nothing. The label under the countdown bar (countdownText, e.g. "Démarrage automatique dans {remaining}") has its own token, {remaining}, re-rendered every second.
The mode is chosen per deployment (--mode, which overrides the manifest). What separates them is visibility, not power: all three end up installing.
| Mode | Gate dialog | Progress / completion message | Closes applications |
|---|---|---|---|
interactive |
yes — deferral and countdown | yes | after consent, or on expiry |
nonInteractive |
no — no choice offered | yes | by force |
silent |
no | no — nothing on screen | by force |
With showDialogWhenNotRunning on (the default), interactive shows its gate — an install notice with the same deferral and countdown — even when no blocking application is running. Turn it off (--show-when-not-running false) and an interactive deployment installs straight away in that case. The other two modes never show a gate.
The non-interactive modes respect the deferral window before closing: as long as neither deadline, nor deferralDays, nor maxDeferrals is reached, they return 64 and let Intune retry. To install without waiting, allowDeferral: false.
macadt-deploy detects running blocking applications itself (libproc, agent side — works in a daemon context), shows the warning dialog (swiftDialog), then closes with SIGKILL.
- No PPPC profile, no AppleEvent, no TCC. Closing is a
kill(2)by PID on the agent side, which depends on no session and requires no authorization. - The dialog (warning + deferral + countdown) is the only window to save one's work: on consent or at the deadline, the application is killed on the spot, with no "Save?" sheet.
persistPromptSecondsbrings an ignored dialog back to the foreground; a deferrable dialog also carries its own countdown, whose expiry triggers the installation.
Four windows, all swiftDialog, all in the bottom-right corner except the final warning:
| Window | When | What it carries |
|---|---|---|
| Gate | interactive, before installing |
message, Defer / Close and install, a full-width countdown bar above the buttons with the time left |
| Final warning | deferrals exhausted | same, centred, screen blurred, no deferral |
| Installation in progress | interactive and nonInteractive, during the copy |
a real 0–100 % bar; can be minimised to the Dock |
| Completion | same modes, after a successful install | closes itself after 20 s |
| Gate | Installation in progress | Completion |
|---|---|---|
![]() |
![]() |
![]() |
macOS 26, French locale, Examples/chrome-test.json.
Things that are not obvious, all established on swiftDialog 3.1.1 / macOS 26 and guarded by tests or comments:
- The countdown is driven by the helper, not by
--timer. swiftDialog draws its timer bar beside the buttons, and a timer whose bar is hidden never expires. The helper advances a--progressbar through the command file and closes the dialog itself at zero. - swiftDialog is launched with a single
--jsonstring. Given as separate arguments, some orders (--progressahead of--positionor--width) leave the process running with no window at all. - No
--ontop, no--small. The first shows no window throughlaunchctl asuser; the second also scales--width/--heightby 0.75 and truncates the buttons. - The window height is computed from the message. swiftDialog does not fit its window to the content.
- The progress percentage is the size copied against the size of the source, sampled every second. A
.pkgpayload gives no usable progress: its bar stays at 0 % until the end.
The hook already exists and requires no modification: Update-IntuneMacOSApp accepts -PreInstallScript / -PostInstallScript for macOSPkgApp, fed by ApplicationIntune.pre_install_script / post_install_script.
Intune — "macOS app (PKG)" app, required
│
├─ installer -pkg [done by Intune — lays down agent + swiftDialog + manifest + payload]
│
└─ post-install script ─> macadt-deploy install [root, daemon context]
├─ process scan (libproc)
├─ dialog + deferral (launchctl asuser → macadt-deploy-ui → swiftDialog)
├─ SIGKILL close (kill by PID, agent side)
├─ DMG mount, .app copy (progress window)
├─ hardening, detection, post-install hook, completion message
├─ cleanup (waits for the completion message to close, then removes
│ agent + swiftDialog + payload; keeps state + logs)
└─ RestartAction
What is frozen in the signed pkg (decided at build, per app/version): the manifest and the payload.
What is dynamic at deployment (text, regenerated by MacADT, no rebuild): the --mode, --show-when-not-running and any pre/post scripts. Changing either = regenerate (or edit) the post-install, the pkg does not move.
install runs the whole chain: copy, then hardening (quarantine and Finder attributes removed, signature verified, root:wheel), the detection verdict, the post-install hook and the completion message. An application copied out of a DMG and left unhardened stays quarantined and owned by the image's build user — it does not open.
The templates in Scripts/templates/ are instantiated by substitution, exactly like InstallExtractAndRun.ps1 and its {##…##} on the Windows side.
macOSDmgApp accepts no script when deployed through Intune. With macADT, a DMG app becomes a macOSPkgApp whose payload is the vendor's DMG. The DMG travels as a third-party file inside the new pkg (never mounted on the build host), then the device mounts it, copies the .app, hardens and unmounts. macadt-deploy also handles a .pkg or an .app inside the DMG, and explicitly refuses an image with several candidates (unless payload.bundleName).
"hooks": { "preInstall": "hooks/pre-install.sh", "postInstall": "hooks/post-install.sh" }Paths relative to the manifest directory, run as root, killed after 600 s. Context arrives through the environment.
preInstall runs after the applications are closed and before the installation.
Security: a script path read from a JSON and run as root is a code-execution primitive. Four safeguards, all checked before execution and tested:
- the resolved path must stay inside the manifest directory (
..refused, absolute path outside rejected); - the script must exist and carry an execute bit;
- it must be owned by root;
- it must not be writable by group or others.
chown root:wheel hooks/pre-install.sh && chmod 755 hooks/pre-install.sh| Code | Meaning |
|---|---|
0 |
Installed, or clear to install |
1–6 |
Failure. 5 = force_close_failed (a process survived the SIGKILL). 6 = hook_failed |
60–65 |
Not installed, legitimate transient block. Intune should retry |
70–72 |
Installed, restart / logout / shutdown required |
Intune only distinguishes 0 / non-zero. Two consequences:
- A user deferral shows up as "failed" on the dashboard, functionally correct (will be retried), unreadable in reporting. The source of truth:
macadt-deploy status --json plutil -p "/Library/Application Support/MacADT/Deploy/state/<packageKey>.plist" log show --predicate 'subsystem == "com.macadt.deploy"' --last 1d cat /var/log/MacADT/deploy.log # global log (base dir overridable via MACADT_LOG_DIR)
- Codes 70–72 are translated to 0 by the post-install, otherwise a successful installation requiring a restart would be reinstalled in a loop.
The state directory survives the cleanup on purpose — a deferral that was counted stays counted. When testing the same package repeatedly, remove it between runs (sudo rm -rf "/Library/Application Support/MacADT/Deploy/state"), or the Defer button disappears once maxDeferrals is reached.
Goes through the same evaluation, builds the same dialog and the same uninstall plan, but executes nothing and consumes no deferral. Works without root.
macadt-deploy pre -m Examples/slack.json --dry-run
macadt-deploy pre -m Examples/slack.json --dry-run --mode Silent
macadt-deploy uninstall -m Examples/slack.json --dry-run --json | jqScripts/build-app-pkg.sh -m <manifest>.json -p <payload>.dmg [--mode …] [--hooks-dir …]
Scripts/build-app-pkg.sh … --unsigned # local validation only (Intune refuses an unsigned pkg)The script compiles the agent as a universal binary (x86_64 arm64), embeds swiftDialog, lays down the manifest + the payload, signs the pkg and renders the post-install. Xcode is not required: when xcbuild is absent, two --triple builds followed by a lipo.
Scripts/build-pkg.sh builds, signs and notarizes the pkg of the agent alone — useful for testing the agent in isolation.
No dependency to install on the device. macadt-deploy relies on swiftDialog for the UI (notarized by its author, MIT licence; markdown, dark mode, Notification Center, accessibility), but it travels inside the pkg: the build scripts copy Dialog.app from the build host, and the post-install removes it after installation. swiftDialog must therefore be present on the build Mac (/Library/Application Support/Dialog/Dialog.app, or MDT_DIALOG_APP).
No Swift dependency. A binary that runs as root on production devices does not drag a supply chain around: argument parsing and manifest format are hand-written. The DialogSpec indirection is the point where one would swap the UI without touching the logic.
| Path | What lives there |
|---|---|
Sources/MacADTKit/ |
Pure-Foundation logic: manifest decoding, deferral engine, uninstall planner, dialog spec, templating, exit codes. No Apple-only API. |
Sources/MacADTMac/ |
Everything that touches the system: libproc process scan, SIGKILL close, DMG mount, installer, hooks, swiftDialog runner, session/IOKit probes. Entirely behind #if canImport(Darwin). |
Sources/MacAppDeployToolkitCLI/ |
The macadt-deploy agent entry point. |
Sources/MacAppDeployToolkitUI/ |
The macadt-deploy-ui helper (runs in the user session, drives swiftDialog). |
Tests/MacADTKitTests/ |
XCTest suite for MacADTKit — the whole decision surface, plus ExamplesTests which parses every file in Examples/. |
Examples/ |
Reference manifests. Every one must parse (a test enforces it). |
Scripts/ |
Build (build-app-pkg.sh, build-pkg.sh), the post-install templates/, and the Intune probe-* / mac-smoke-test.sh runbooks. |
Put every decision — anything with a branch, a threshold or an edge case — in MacADTKit, behind a pure function or value type, and let MacADTMac only perform the effect. The payoff is that the costly-to-get-wrong code is pure and unit-tested, with no system state to stand up or mock. If you find yourself writing an if inside MacADTMac, ask whether that decision belongs in MacADTKit with a test next to it.
swift build
swift test # requires full Xcode — XCTest is not in the Command Line Tools
Scripts/mac-smoke-test.sh # end-to-end validation runbook on a MacMacADTKit holds all the decision logic and carries the whole XCTest suite. MacADTMac and the two executables are macOS-only and are covered by the smoke test rather than by unit tests.
swift test needs full Xcode: with the Command Line Tools alone it fails with no such module 'XCTest'. The build scripts, on the other hand, produce a universal binary without Xcode (two --triple builds + lipo), so a CLT-only Mac can still ship a pkg.
- No external dependency, ever. This binary runs as root on production devices; the supply-chain surface must stay nil. Argument parsing and the manifest format are hand-written on purpose — keep them that way.
- Allowlist, not denylist, for anything that deletes or targets a path (see the uninstall planner). Compare by path components, never by string prefix.
- Don't hardcode user-facing text. End-user strings come from the
Messagescatalog (Manifest.swift), which ships built-in. Add a string to the catalog with both locales; never inline a dialog label inMacADTMac. - Prefer a value you can test over a side effect you can't. The uninstall plan is a pure value; the dialog is a
DialogSpecbuilt inMacADTKit; MacADTMac just executes them.
- Add a manifest field → extend the type in
Sources/MacADTKit/Manifest.swift(tolerant decoding, every key optional with a fallback), document it in the manifest table above, and add a case toTests/MacADTKitTests/ManifestTests.swift. - Add an example → drop a
.jsoninExamples/;ExamplesTestswill require it to parse, so keep it valid. Use real data for real apps, English for prose and_commentaire. - Change the post-install → edit
Scripts/templates/macos-postinstall-standalone.sh.template. Tokens are{##PACKAGE_KEY##}/{##DEPLOY_MODE##}and must never appear in a comment line (a test checks this); the rendered script must passsh -n. - Add an exit code → put the meaning in
Sources/MacADTKit/ExitCode.swiftand reflect it in the exit-code table above and, if the post-install must swallow it, in the template's code translation.
From the daemon context where Intune runs its scripts, there is no access to the WindowServer: no UI is possible. The dialog must therefore show up in the user session, hence the macadt-deploy-ui helper, launched with launchctl asuser and sudo -u (the first for the session context, the second so the dialog shows up on the user's behalf). Closing applications, on the other hand, happens on the agent side via SIGKILL. The agent↔helper dialog goes through stdin/stdout as JSON, one invocation per exchange.
| Component | Status |
|---|---|
MacADTKit — decision logic, manifest, uninstall plan |
Unit tests green |
MacADTMac — Apple system calls (libproc, SCDynamicStore, IOKit) |
Compiles and runs — validated on macOS 26.3.2 / arm64 |
macadt-deploy / macadt-deploy-ui executables |
Exercised end to end: gate, deferral, deadline, SIGKILL close, installation of a real DMG, all three modes |
Self-contained pkg build (build-app-pkg.sh) |
Operational, signs the pkg on the build host |
The split into two modules is deliberate: MacADTKit is pure Foundation and carries all the logic where a mistake is costly, under unit test; MacADTMac is entirely behind #if canImport(Darwin).


