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:
@@ -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."
|
||||
}
|
||||
Reference in New Issue
Block a user