#!/usr/bin/env bash # lib/macos-prefs.sh — snapshot and restore macOS preference domains. # # Sync writes one plist per domain to /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 com.apple.controlcenter # Control Centre module toggles (Wi-Fi, BT, Focus…) com.apple.systemuiserver # menu-bar extras order and extras list com.apple.notificationcenterui # Notification Centre widget layout 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 /macos-prefs/.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. # cfprefsd first so the fresh plists are visible to other processes. local svc for svc in cfprefsd Dock Finder SystemUIServer ControlCenter; do killall "$svc" 2>/dev/null || true done ssr::ok "Restarted Dock, Finder, SystemUIServer, ControlCenter, cfprefsd." }