Snapshot and restore macOS preference domains

Adds a round-trip mechanism for macOS system preferences that are not
covered by Homebrew or Mackup (Dock layout, Finder options, global
keyboard shortcuts, input sources, Spaces config, menu-bar clock,
NSGlobalDomain).

sync: `ssr sync` exports each configured domain with `defaults export`
and writes one plist per domain to <backup_dir>/macos-prefs/.  Export is
soft-fail per domain so one bad domain doesn't abort the sync. Gated by
SSR_SNAPSHOT_MACOS_PREFS=true (default on).

restore: `ssr restore` replays the snapshot with `defaults import` after
the declarative config/macos-defaults.sh runs, so the snapshot is the
last writer and wins for accepted domains.  Before importing, the restore
detects domains that both the snapshot and macos-defaults.sh touch and
prompts interactively:

  Domain `com.apple.dock`:
    Snapshot will OVERRIDE values set by macos-defaults.sh.
    [A]ccept snapshot / [r]eject (keep declarative)? [A/r]:

Non-TTY (launchd, CI) falls back to SSR_PREFS_CONFLICT_DEFAULT (default
"accept"). SSR_ASSUME_YES=true skips all prompts. After import, Dock,
Finder, SystemUIServer and cfprefsd are restarted once.

New config keys: SSR_SNAPSHOT_MACOS_PREFS, SSR_MACOS_PREF_DOMAINS,
SSR_MACOS_PREF_DOMAINS_EXTRA, SSR_PREFS_CONFLICT_DEFAULT, SSR_ASSUME_YES.

ssr-status surfaces the snapshotted-domain count.
README documents the snapshot flow, default domain table, conflict UX,
and the five new config keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 20:19:49 +02:00
parent d681792148
commit 5ed6279b51
6 changed files with 330 additions and 7 deletions
+52 -2
View File
@@ -71,7 +71,11 @@ Output structure in the cloud drive:
│ └── <hostname> - <hardware-uuid>/
│ ├── Brewfile
│ ├── Brewfile.bak
── unmanaged-apps.md # apps installed manually (not brew/MAS)
── unmanaged-apps.md # apps installed manually (not brew/MAS)
│ └── macos-prefs/ # pref-domain snapshots
│ ├── com.apple.dock.plist
│ ├── com.apple.finder.plist
│ └── …
└── Mackup/
└── … (managed by mackup)
```
@@ -89,6 +93,46 @@ Link selection for each app uses, in order:
2. **Known vendor map** — bundle-id prefix maps to the official download page (Adobe, Microsoft, Docker, Cursor, Anthropic, JetBrains, GitKraken, Signal, Zoom, etc.).
3. **Web search** — DuckDuckGo query for `<App name> macOS download`.
### macOS preferences snapshot
`ssr sync` also snapshots the live state of a curated set of macOS preference domains, writing one plist per domain to `<backup_dir>/macos-prefs/`. During `ssr restore` these are replayed **after** `config/macos-defaults.sh`, so the snapshot is the last writer.
Default domains:
| Domain | What it covers |
|---|---|
| `com.apple.dock` | Dock position, app layout, hot corners |
| `com.apple.finder` | View options, sidebar, path bar |
| `com.apple.symbolichotkeys` | Global keyboard shortcuts (Spotlight, Mission Control, Screenshots…) |
| `com.apple.HIToolbox` | Input sources / keyboard layouts |
| `com.apple.spaces` | Spaces / Mission Control layout |
| `com.apple.menuextra.clock` | Menu-bar clock format |
| `NSGlobalDomain` | Global UI prefs, key-repeat speed, sound, language |
**Conflict resolution during restore**
When a snapshot domain overlaps with a domain that `config/macos-defaults.sh` also writes to, `ssr restore` warns and asks interactively:
```
[WARN] Pref-snapshot overlaps with declarative defaults for 2 domain(s):
com.apple.dock, com.apple.finder
Domain `com.apple.dock`:
Snapshot from last sync (2026-05-11T17:30:00+0200) will OVERRIDE values
set by macos-defaults.sh.
[A]ccept snapshot / [r]eject (keep declarative)? [A/r]:
```
Default = `Accept` (press Enter). Reply `r` to keep the declarative value for that domain. In non-interactive contexts (launchd, CI) the decision falls back to `SSR_PREFS_CONFLICT_DEFAULT` (default `accept`). Set `SSR_ASSUME_YES=true` to skip all prompts.
Extend the domain set in `~/.config/ssr/ssr.conf`:
```bash
SSR_MACOS_PREF_DOMAINS_EXTRA="com.apple.universalaccess com.apple.Spotlight"
```
> **Mackup overlap:** if you also configure mackup to sync `com.apple.dock` or similar domains, two mechanisms will compete. Pick one.
### Restore on a new machine
```bash
@@ -119,6 +163,11 @@ Config lives in `~/.config/ssr/ssr.conf` (override with `--config` or `$SSR_CONF
| `SSR_BREW_CLEANUP` | `true` | Run `brew cleanup` after sync |
| `SSR_SCAN_UNMANAGED_APPS` | `true` | List `.app` bundles not from brew/MAS during sync |
| `SSR_APP_DIRS` | `/Applications:~/Applications` | Colon-separated dirs scanned for unmanaged apps |
| `SSR_SNAPSHOT_MACOS_PREFS` | `true` | Snapshot macOS pref domains during sync |
| `SSR_MACOS_PREF_DOMAINS` | _(see below)_ | Full override of snapshotted domains (space-separated) |
| `SSR_MACOS_PREF_DOMAINS_EXTRA` | — | Extra domains appended to the default set |
| `SSR_PREFS_CONFLICT_DEFAULT` | `accept` | Non-TTY conflict resolution: `accept` or `reject` |
| `SSR_ASSUME_YES` | `false` | Auto-accept all pref-snapshot conflicts on restore |
| `SSR_DISABLE_GATEKEEPER` | `false` | Run `spctl --global-disable` during restore (security trade-off) |
| `SSR_OPEN_CASKS` | `false` | Open every installed cask after restore for first-run config |
| `SSR_MACOS_DEFAULTS` | repo's `config/macos-defaults.sh` | Override path to your own defaults script |
@@ -234,7 +283,8 @@ ssr update --dry-run # show what would change, don't pull
│ ├── brew.sh # Homebrew install + Brewfile ops
│ ├── mackup.sh # Mackup install + backup/restore
│ ├── apps.sh # Detect unmanaged .app bundles (not brew/MAS)
── macos.sh # macOS-specific actions (Rosetta, defaults, etc.)
── macos.sh # macOS-specific actions (Rosetta, defaults, etc.)
│ └── macos-prefs.sh # Snapshot/restore macOS pref domains
├── config/
│ ├── ssr.conf.example # Reference user config
│ └── macos-defaults.sh # Default macOS preferences
+8
View File
@@ -13,6 +13,8 @@ source "$SSR_ROOT/lib/brew.sh"
source "$SSR_ROOT/lib/mackup.sh"
# shellcheck source=../lib/macos.sh
source "$SSR_ROOT/lib/macos.sh"
# shellcheck source=../lib/macos-prefs.sh
source "$SSR_ROOT/lib/macos-prefs.sh"
ssr::require_macos
ssr::load_config
@@ -54,4 +56,10 @@ ssr::mackup::restore
ssr::macos::open_installed_casks
ssr::macos::apply_defaults
# Import the macOS pref snapshot AFTER the declarative defaults so the
# snapshot is the last writer (wins for accepted domains).
if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then
ssr::macos::prefs_import "$backup_dir"
fi
ssr::ok "ssr restore completed"
+8 -3
View File
@@ -30,11 +30,16 @@ else
echo " last sync: never"
fi
# Surface unmanaged-app count from the last sync, if a report exists.
if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)" \
&& [[ -f "$backup_dir/unmanaged-apps.md" ]]; then
# Surface unmanaged-app count and pref-snapshot summary from the last sync.
if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)"; then
if [[ -f "$backup_dir/unmanaged-apps.md" ]]; then
n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)"
echo " unmanaged: $n app(s) (see $backup_dir/unmanaged-apps.md)"
fi
if [[ -d "$backup_dir/macos-prefs" ]]; then
n="$(find "$backup_dir/macos-prefs" -name '*.plist' 2>/dev/null | wc -l | tr -d ' ')"
echo " prefs snap: $n domain(s) (see $backup_dir/macos-prefs/)"
fi
fi
label="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}"
+8
View File
@@ -13,6 +13,8 @@ source "$SSR_ROOT/lib/brew.sh"
source "$SSR_ROOT/lib/mackup.sh"
# shellcheck source=../lib/apps.sh
source "$SSR_ROOT/lib/apps.sh"
# shellcheck source=../lib/macos-prefs.sh
source "$SSR_ROOT/lib/macos-prefs.sh"
ssr::require_macos
ssr::load_config
@@ -54,6 +56,12 @@ if [[ "${SSR_SCAN_UNMANAGED_APPS:-true}" == "true" ]]; then
fi
fi
# Snapshot macOS preference domains so they can be restored on a new machine.
if [[ "${SSR_SNAPSHOT_MACOS_PREFS:-true}" == "true" ]]; then
ssr::log "Snapshotting macOS preferences"
ssr::macos::prefs_export "$backup_dir"
fi
# Record sync time so `ssr status` can report freshness.
state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
ssr::ensure_dir "$state_dir"
+21
View File
@@ -32,6 +32,27 @@ SSR_SCAN_UNMANAGED_APPS=true
# Colon-separated directories to scan (top level only).
# SSR_APP_DIRS=/Applications:$HOME/Applications
# ---------------------------------------------------------------------------
# macOS preferences snapshot (sync + restore)
# ---------------------------------------------------------------------------
# Snapshot configured pref domains to <backup_dir>/macos-prefs/ during sync
# and replay them at the end of restore (after macos-defaults.sh).
SSR_SNAPSHOT_MACOS_PREFS=true
# Full override of the domain list (space-separated). Uncomment to replace
# the built-in default set.
# SSR_MACOS_PREF_DOMAINS="com.apple.dock com.apple.finder com.apple.symbolichotkeys com.apple.HIToolbox com.apple.spaces com.apple.menuextra.clock NSGlobalDomain"
# Append extra domains to the default set without replacing it.
# SSR_MACOS_PREF_DOMAINS_EXTRA="com.apple.universalaccess com.apple.Spotlight"
# How to resolve conflicts when restore is non-interactive (no TTY, launchd).
# "accept" = snapshot wins; "reject" = declarative macos-defaults.sh wins.
SSR_PREFS_CONFLICT_DEFAULT=accept
# Set to "true" to skip all conflict prompts and auto-accept everywhere.
# SSR_ASSUME_YES=false
# ---------------------------------------------------------------------------
# macOS restore behavior
# ---------------------------------------------------------------------------
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# lib/macos-prefs.sh — snapshot and restore macOS preference domains.
#
# Sync writes one plist per domain to <backup_dir>/macos-prefs/.
# Restore replays those plists with `defaults import`, but first checks for
# overlap with the declarative config/macos-defaults.sh and prompts the user
# to Accept (snapshot wins) or Reject (declarative wins) per domain.
#
# Uses the `defaults` daemon API (never raw file copies) so cfprefsd is
# always the authoritative source and caching issues are avoided.
# ---------------------------------------------------------------------------
# Default domain list (user can override or append via ssr.conf)
# ---------------------------------------------------------------------------
SSR_MACOS_PREF_DOMAINS_DEFAULT=(
com.apple.dock
com.apple.finder
com.apple.symbolichotkeys # global keyboard shortcuts (Mission Control, Spotlight…)
com.apple.HIToolbox # input sources / keyboard layout
com.apple.spaces # Spaces / Mission Control layout
com.apple.menuextra.clock # menu-bar clock format
NSGlobalDomain # a.k.a. .GlobalPreferences (UI/keyboard-repeat/sound…)
)
# Build the effective domain list: defaults + optional extra from config.
ssr::macos::prefs_domains() {
local -a list=("${SSR_MACOS_PREF_DOMAINS_DEFAULT[@]}")
if [[ -n "${SSR_MACOS_PREF_DOMAINS:-}" ]]; then
# Full override: space-separated string from config.
read -ra list <<< "$SSR_MACOS_PREF_DOMAINS"
fi
if [[ -n "${SSR_MACOS_PREF_DOMAINS_EXTRA:-}" ]]; then
# Append-only: space-separated string from config.
local extra
read -ra extra <<< "$SSR_MACOS_PREF_DOMAINS_EXTRA"
list+=("${extra[@]}")
fi
printf '%s\n' "${list[@]}" | sort -u
}
# ---------------------------------------------------------------------------
# Export (sync side)
# ---------------------------------------------------------------------------
# Export all configured domains to <backup_dir>/macos-prefs/<domain>.plist.
# Each domain is exported independently; failures are logged and skipped.
ssr::macos::prefs_export() {
local backup_dir="$1"
local prefs_dir="$backup_dir/macos-prefs"
ssr::ensure_dir "$prefs_dir"
local domain out exported=0 failed=0
while IFS= read -r domain; do
[[ -n "$domain" ]] || continue
out="$prefs_dir/${domain}.plist"
# `defaults export` writes a binary plist to the given path.
# Redirect stdout away; errors go to stderr.
if defaults export "$domain" "$out" 2>/dev/null; then
exported=$(( exported + 1 ))
else
ssr::warn "prefs-export: could not export domain '$domain' (skipped)"
failed=$(( failed + 1 ))
fi
done < <(ssr::macos::prefs_domains)
ssr::ok "Snapshotted $exported macOS pref domain(s) → $prefs_dir"
[[ $failed -eq 0 ]] || ssr::warn "$failed domain(s) could not be exported."
}
# ---------------------------------------------------------------------------
# Internal helpers for the restore side
# ---------------------------------------------------------------------------
# Parse the declarative defaults script and return unique domain names that
# it writes to (one per line, sorted).
# Handles both `defaults write DOMAIN …` and `sudo defaults write DOMAIN …`.
ssr::macos::_decl_domains() {
local script="$1"
[[ -f "$script" ]] || return 0
# Match both:
# defaults write DOMAIN …
# run defaults write DOMAIN …
# sudo defaults write DOMAIN …
grep -hE '(^|[[:space:]])defaults[[:space:]]+write[[:space:]]+' "$script" \
| awk '{
for (i = 1; i <= NF; i++) {
if ($i == "write") { print $(i+1); break }
}
}' \
| grep -v '^#' \
| sort -u
}
# Interactive or batch conflict resolution for one domain.
# Returns 0 = Accept (snapshot will be imported), 1 = Reject (skip import).
# Respects:
# SSR_ASSUME_YES=true → always accept without prompting
# non-TTY (stdin is not a terminal) → use SSR_PREFS_CONFLICT_DEFAULT
# SSR_PREFS_CONFLICT_DEFAULT=reject → default action when batch/non-TTY
ssr::macos::_prompt_conflict() {
local domain="$1"
local snap_ts="${2:-unknown}"
# Honour SSR_ASSUME_YES.
if [[ "${SSR_ASSUME_YES:-false}" == "true" ]]; then
ssr::log " Accepted snapshot for $domain (SSR_ASSUME_YES)"
return 0
fi
# Non-interactive fallback.
if [[ ! -t 0 ]]; then
local default="${SSR_PREFS_CONFLICT_DEFAULT:-accept}"
if [[ "$default" == "reject" ]]; then
ssr::warn " Non-TTY: rejected snapshot for $domain (SSR_PREFS_CONFLICT_DEFAULT=reject)"
return 1
else
ssr::log " Non-TTY: accepted snapshot for $domain (SSR_PREFS_CONFLICT_DEFAULT=accept)"
return 0
fi
fi
# Interactive prompt.
printf '\n'
ssr::warn "Domain \`%s\` — snapshot (from %s) will OVERRIDE values set by macos-defaults.sh." \
"$domain" "$snap_ts"
local reply
read -r -n 1 -p " [A]ccept snapshot / [r]eject (keep declarative)? [A/r]: " reply || reply=""
printf '\n'
case "$reply" in
r|R)
ssr::ok " Rejected snapshot for $domain; declarative values kept."
return 1
;;
*)
ssr::ok " Accepted snapshot for $domain."
return 0
;;
esac
}
# ---------------------------------------------------------------------------
# Import (restore side)
# ---------------------------------------------------------------------------
# Orchestrate the import:
# 1. Compute conflicts with the declarative script.
# 2. Warn about overlapping domains and prompt per domain.
# 3. Run defaults import for each accepted domain.
# 4. Restart affected macOS services once.
#
# The declarative config/macos-defaults.sh runs *before* this function is
# called (in bin/ssr-restore), so the snapshot is the last writer and
# "wins" for accepted domains.
ssr::macos::prefs_import() {
local backup_dir="$1"
local prefs_dir="$backup_dir/macos-prefs"
if [[ ! -d "$prefs_dir" ]] || ! ls "$prefs_dir"/*.plist >/dev/null 2>&1; then
ssr::warn "No macOS pref snapshot found in $prefs_dir — skipping import."
return 0
fi
# Determine snapshot timestamp from the directory mtime.
local snap_ts
snap_ts="$(date -r "$prefs_dir" '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo 'unknown')"
# Domains we have a snapshot for.
local -a snap_domains=()
local f
for f in "$prefs_dir"/*.plist; do
local d
d="${f##*/}"; d="${d%.plist}"
snap_domains+=("$d")
done
# Domains touched by the declarative script.
local decl_script="${SSR_MACOS_DEFAULTS:-$SSR_ROOT/config/macos-defaults.sh}"
local decl_domains
decl_domains="$(ssr::macos::_decl_domains "$decl_script" 2>/dev/null || true)"
# Find overlapping domains (snapshot ∩ declarative).
local -a conflicts=()
local d
for d in "${snap_domains[@]}"; do
if printf '%s\n' "$decl_domains" | grep -qFx "$d"; then
conflicts+=("$d")
fi
done
if [[ ${#conflicts[@]} -gt 0 ]]; then
ssr::warn "Pref-snapshot overlaps with declarative defaults for ${#conflicts[@]} domain(s):"
ssr::warn " $(printf '%s, ' "${conflicts[@]}" | sed 's/, $//')"
fi
# Resolve conflicts interactively; build the final accepted list.
local -a rejected=()
for d in "${conflicts[@]}"; do
ssr::macos::_prompt_conflict "$d" "$snap_ts" || rejected+=("$d")
done
# Import all snapshot domains except rejected ones.
local imported=0 skipped=0
for d in "${snap_domains[@]}"; do
local plist="$prefs_dir/${d}.plist"
# Check if this domain was rejected.
local is_rejected=false
local r
for r in "${rejected[@]}"; do
[[ "$r" == "$d" ]] && is_rejected=true && break
done
if $is_rejected; then
skipped=$(( skipped + 1 ))
continue
fi
if defaults import "$d" "$plist" 2>/dev/null; then
imported=$(( imported + 1 ))
else
ssr::warn "prefs-import: could not import domain '$d' (skipped)"
skipped=$(( skipped + 1 ))
fi
done
ssr::ok "Imported $imported macOS pref domain(s)${skipped:+ ($skipped skipped)}."
# Restart affected services once so changes take effect.
local svc
for svc in Dock Finder SystemUIServer cfprefsd; do
killall "$svc" 2>/dev/null || true
done
ssr::ok "Restarted Dock, Finder, SystemUIServer, cfprefsd."
}