Staged pref restore
shft migrates app preferences alongside user data, but on a fresh destination Mac the matching apps usually aren't installed yet — the user or MDM installs them after the migration finishes. The classic problem this creates: prefs dumped into ~/Library/... before the app exists get overwritten when the app is later installed from clean state. By the time the user actually launches the app, their migrated settings are gone.
Staged pref restore solves this by treating the destination's ~/Library/... as the final location, not the landing location. Prefs land in a holding directory after the migration completes and only move to their real path the moment the matching app is detected on disk.
Modes
shft.prefRestoreMode chooses the policy:
-
immediate— legacy. Prefs land directly under~/Library/...during the transfer. Cheapest, but loses prefs for apps that aren't yet installed and are subsequently installed from clean state. -
staged(default) — after eachapplicationDatacategory finishes, the files are moved into~/.shft-staged-prefs/<bundleKey>/. A file system watcher on/Applicationsand~/Applicationsapplies each bundle's files back to their real destination the moment the matching app appears. -
manual— same staging as above, but no automatic apply. The user triggers each bundle's restore from the "Staged Preferences" sheet in the shft UI. Useful for IT-driven flows where the operator wants explicit control over when prefs land.
shft.prefRestoreWindowDays (default 7) controls how long a staged bundle is kept. If the trigger app hasn't installed by then, the bundle is surfaced to the user once on the next launch and then discarded.
The holding directory
~/.shft-staged-prefs/
├── com.tinyspeck.slackmacgap/ ← generic per-app bundle
│ ├── bundle.json
│ └── Library/Preferences/com.tinyspeck.slackmacgap.plist
│
├── _microsoft-office/ ← group bundle for the Office suite
│ ├── bundle.json
│ ├── Library/Group Containers/UBF8T346G9.Office/...
│ └── Library/Preferences/com.microsoft.Word.plist
│
└── _adobe-creative-cloud/ ← group bundle for the Adobe suite
├── bundle.json
└── Library/Application Support/Adobe/...
Each subdirectory is one bundle. bundle.json is the manifest (one StagedPrefBundle) holding the display name, list of trigger app IDs, expiry time, and per-file destination paths. The mirrored Library/... layout makes the bundle directly inspectable by a human.
Trigger mapping
The classifier is in AppPrefMapper. It picks one of two paths:
-
Group profiles for app suites that share a directory or use non-reverse-DNS folders:
Bundle key Apps that trigger Paths that go in _microsoft-officeevery com.microsoft.*Office app + Teams + OneDrive~/Library/Group Containers/UBF8T346G9.*,~/Library/Preferences/com.microsoft.*,~/Library/Application Support/Microsoft,~/Library/Containers/com.microsoft.*_adobe-creative-cloudthe Creative Cloud launcher + every CC app ~/Library/Application Support/Adobe,~/Library/Preferences/Adobe,~/Library/Preferences/com.adobe.*,~/Library/Caches/Adobe_jetbrains-idesevery JetBrains IDE bundle ID + Toolbox ~/Library/Application Support/JetBrains,~/Library/Preferences/jetbrains*,~/Library/Preferences/com.jetbrains.*,~/Library/Caches/JetBrainsGroup bundles are restored once, the first time any member app is observed installed — installing Photoshop after Illustrator doesn't double-restore.
-
Generic fallback for reverse-DNS plists and bundles. A file at
~/Library/Preferences/com.foo.plist,~/Library/Application Support/com.foo/...,~/Library/Containers/com.foo/..., or~/Library/Group Containers/com.foo/...is staged under bundle keycom.foo, triggered by bundle IDcom.fooarriving. -
Anything else lands in the catch-all
_unclassifiedbundle and requires the user to apply it manually — we don't know what app would trigger it.
MDM pre-stage
When you know in advance which apps the MDM will install, ship a manifest so the staging UI can label the bundles correctly and so a zero-touch operator knows what's coming. Two profile keys carry it:
shft.mdmAppManifestPath— path to a JSON file on the Mac.shft.mdmAppManifestJSON— the JSON inline, encoded as a profile string value. Wins over the file path when both are set.
Schema (MDMAppManifest):
{
"apps": [
{
"bundleID": "com.microsoft.Word",
"displayName": "Microsoft Word",
"expectedAt": "2026-05-20T18:00:00Z"
},
{
"bundleID": "com.adobe.Photoshop",
"displayName": "Adobe Photoshop"
}
]
}displayName and expectedAt are optional — expectedAt is advisory only (shft doesn't poll the MDM, just shows it in the UI). The manifest doesn't itself create staged bundles — prefs still have to arrive from a real migration. It controls labelling and reporting.
Homebrew
Homebrew is migrated separately by the engineeringTools category as a Brewfile-migration-<date>.txt dropped on the Desktop. The pref-restore service runs a small HomebrewWatcher on the side that polls for brew at the two standard prefixes (/opt/homebrew/bin/brew for Apple Silicon, /usr/local/bin/brew for Intel). When brew is detected, the "Staged Preferences" sheet shows a banner telling the user to run brew bundle install against the migrated Brewfile to restore their packages. shft doesn't run brew bundle automatically — that needs the user's consent for network installs.
Conflict resolution at apply time
By default a staged bundle overwrites any pre-existing file at the destination path. The assumption is that an app has just appeared for the first time, so the user can't have customised it yet — restored prefs win.
PrefStager.apply(bundleKey:onConflict:) accepts a .skip mode for cases where the bundle is being applied at the user's manual request against an app they've already opened; the UI exposes that as a future follow-up.