From 5ed6279b5171302d5fecaddc8ffcacf317be7c10 Mon Sep 17 00:00:00 2001 From: "Oliver Surke (Gitea)" Date: Mon, 11 May 2026 20:19:49 +0200 Subject: [PATCH] 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 /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 --- README.md | 54 +++++++++- bin/ssr-restore | 8 ++ bin/ssr-status | 15 ++- bin/ssr-sync | 8 ++ config/ssr.conf.example | 21 ++++ lib/macos-prefs.sh | 231 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 lib/macos-prefs.sh diff --git a/README.md b/README.md index 6f10804..a38443f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,11 @@ Output structure in the cloud drive: │ └── - / │ ├── 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 ` 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 `/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 diff --git a/bin/ssr-restore b/bin/ssr-restore index c3b2229..a6cd74a 100755 --- a/bin/ssr-restore +++ b/bin/ssr-restore @@ -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" diff --git a/bin/ssr-status b/bin/ssr-status index 59acb6c..5abf09c 100755 --- a/bin/ssr-status +++ b/bin/ssr-status @@ -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 - n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)" - echo " unmanaged: $n app(s) (see $backup_dir/unmanaged-apps.md)" +# 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}" diff --git a/bin/ssr-sync b/bin/ssr-sync index e73afdf..54f61f4 100755 --- a/bin/ssr-sync +++ b/bin/ssr-sync @@ -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" diff --git a/config/ssr.conf.example b/config/ssr.conf.example index da4377e..307c799 100644 --- a/config/ssr.conf.example +++ b/config/ssr.conf.example @@ -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 /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 # --------------------------------------------------------------------------- diff --git a/lib/macos-prefs.sh b/lib/macos-prefs.sh new file mode 100644 index 0000000..3f6563b --- /dev/null +++ b/lib/macos-prefs.sh @@ -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 /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 /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. + local svc + for svc in Dock Finder SystemUIServer cfprefsd; do + killall "$svc" 2>/dev/null || true + done + ssr::ok "Restarted Dock, Finder, SystemUIServer, cfprefsd." +}