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