From 4f49e2099bb51c9d8aa904b22c86f4b6a83fdfff Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Sun, 7 Jun 2026 23:33:18 +0800 Subject: [PATCH 01/15] feat(snapshot): add DockApps and LoginItems fields Introduces the LoginItem struct and adds DockApps []string and LoginItems []LoginItem (both omitempty) to the Snapshot struct, with round-trip JSON tests confirming serialisation and omitempty behaviour. Co-Authored-By: Claude Sonnet 4.6 --- internal/snapshot/snapshot.go | 12 ++++++++++++ internal/snapshot/snapshot_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 750ed13..6065f42 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -23,9 +23,21 @@ type Snapshot struct { DevTools []DevTool `json:"dev_tools"` MatchedPreset string `json:"matched_preset"` CatalogMatch CatalogMatch `json:"catalog_match"` + DockApps []string `json:"dock_apps,omitempty"` + LoginItems []LoginItem `json:"login_items,omitempty"` Health CaptureHealth `json:"health"` } +// LoginItem represents one entry under System Events → Login Items. +// Name is the System Events identifier (used for delete + recreate); +// it is preserved verbatim from capture rather than derived from Path, +// because users may rename items. +type LoginItem struct { + Name string `json:"name"` + Path string `json:"path"` + Hidden bool `json:"hidden,omitempty"` +} + type DotfilesSnapshot struct { RepoURL string `json:"repo_url,omitempty"` } diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go index bf4ffb3..074f95d 100644 --- a/internal/snapshot/snapshot_test.go +++ b/internal/snapshot/snapshot_test.go @@ -3,6 +3,7 @@ package snapshot import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -219,6 +220,34 @@ func TestMacOSPref_JSON_LegacyDecodeNoUnsetField(t *testing.T) { assert.False(t, pref.Unset) } +func TestSnapshot_RoundTripDockAppsAndLoginItems(t *testing.T) { + in := &Snapshot{ + Version: 1, + CapturedAt: time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC), + Hostname: "mac", + DockApps: []string{"/Applications/Chrome.app", "/Applications/Zed.app"}, + LoginItems: []LoginItem{ + {Name: "Maccy", Path: "/Applications/Maccy.app", Hidden: false}, + {Name: "BetterDisplay", Path: "/Applications/BetterDisplay.app", Hidden: true}, + }, + } + b, err := json.Marshal(in) + require.NoError(t, err) + + var out Snapshot + require.NoError(t, json.Unmarshal(b, &out)) + assert.Equal(t, in.DockApps, out.DockApps) + assert.Equal(t, in.LoginItems, out.LoginItems) +} + +func TestSnapshot_OmitemptyDropsEmptyArrays(t *testing.T) { + in := &Snapshot{Version: 1} + b, err := json.Marshal(in) + require.NoError(t, err) + assert.NotContains(t, string(b), "dock_apps") + assert.NotContains(t, string(b), "login_items") +} + func TestCaptureHealth(t *testing.T) { t.Run("default is healthy", func(t *testing.T) { snap := &Snapshot{Version: 1} From ab1c05dad0e7f48e5670fda81734b4eae8c27687 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Sun, 7 Jun 2026 23:36:51 +0800 Subject: [PATCH 02/15] feat(snapshot): add Dock persistent-apps JSON parser Co-Authored-By: Claude Sonnet 4.6 --- internal/snapshot/dock.go | 61 ++++++++++++++++++++++++++++++++++ internal/snapshot/dock_test.go | 51 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/snapshot/dock.go create mode 100644 internal/snapshot/dock_test.go diff --git a/internal/snapshot/dock.go b/internal/snapshot/dock.go new file mode 100644 index 0000000..5ab6004 --- /dev/null +++ b/internal/snapshot/dock.go @@ -0,0 +1,61 @@ +package snapshot + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// dockTile is the subset of a persistent-apps entry we care about. +type dockTile struct { + TileType string `json:"tile-type"` + TileData struct { + FileData struct { + CFURLString string `json:"_CFURLString"` + } `json:"file-data"` + } `json:"tile-data"` +} + +// parseDockAppsJSON extracts absolute app paths from the JSON output of +// `defaults export com.apple.dock - | plutil -extract persistent-apps json -o - -`. +// Non-app tiles (folders, stacks, spacers) are skipped per spec. +func parseDockAppsJSON(data []byte) ([]string, error) { + var tiles []dockTile + if err := json.Unmarshal(data, &tiles); err != nil { + return nil, fmt.Errorf("parse dock plist json: %w", err) + } + apps := make([]string, 0, len(tiles)) + for _, t := range tiles { + if t.TileType != "file-tile" { + continue + } + raw := t.TileData.FileData.CFURLString + if raw == "" { + continue + } + path, err := dockURLToPath(raw) + if err != nil { + continue + } + apps = append(apps, path) + } + return apps, nil +} + +// dockURLToPath converts a `file:///Applications/Foo.app/` URL into the +// filesystem path `/Applications/Foo.app`. +func dockURLToPath(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", err + } + if u.Scheme != "file" { + return "", fmt.Errorf("expected file:// url, got %q", u.Scheme) + } + p, err := url.PathUnescape(u.Path) + if err != nil { + return "", err + } + return strings.TrimRight(p, "/"), nil +} diff --git a/internal/snapshot/dock_test.go b/internal/snapshot/dock_test.go new file mode 100644 index 0000000..a31a72b --- /dev/null +++ b/internal/snapshot/dock_test.go @@ -0,0 +1,51 @@ +package snapshot + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDockAppsJSON_Empty(t *testing.T) { + got, err := parseDockAppsJSON([]byte(`[]`)) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestParseDockAppsJSON_TwoApps(t *testing.T) { + input := []byte(`[ + {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/Google%20Chrome.app/","_CFURLStringType":15}},"tile-type":"file-tile"}, + {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/Zed.app/","_CFURLStringType":15}},"tile-type":"file-tile"} + ]`) + got, err := parseDockAppsJSON(input) + require.NoError(t, err) + assert.Equal(t, []string{ + "/Applications/Google Chrome.app", + "/Applications/Zed.app", + }, got) +} + +func TestParseDockAppsJSON_NonAppTileSkipped(t *testing.T) { + input := []byte(`[ + {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/Zed.app/","_CFURLStringType":15}},"tile-type":"file-tile"}, + {"tile-data":{"arrangement":2,"displayas":1},"tile-type":"directory-tile"} + ]`) + got, err := parseDockAppsJSON(input) + require.NoError(t, err) + assert.Equal(t, []string{"/Applications/Zed.app"}, got) +} + +func TestParseDockAppsJSON_NonASCIIPath(t *testing.T) { + input := []byte(`[ + {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/%E5%BE%AE%E4%BF%A1.app/","_CFURLStringType":15}},"tile-type":"file-tile"} + ]`) + got, err := parseDockAppsJSON(input) + require.NoError(t, err) + assert.Equal(t, []string{"/Applications/微信.app"}, got) +} + +func TestParseDockAppsJSON_MalformedJSON(t *testing.T) { + _, err := parseDockAppsJSON([]byte(`not json`)) + assert.Error(t, err) +} From afc0dba2935be78d34bd0280a18719879f36e15d Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:21:02 +0800 Subject: [PATCH 03/15] feat(snapshot): capture Dock pinned apps as first-class field Wires CaptureDockApps (sh -c pipeline: defaults export | plutil -extract) into CaptureWithProgress as a new step after "macOS Preferences", populates CaptureResults.DockApps, and copies it through assembleSnapshot to Snapshot.DockApps. Adds a no-panic integration guard test for the live capture path. Co-Authored-By: Claude Sonnet 4.6 --- internal/snapshot/capture.go | 10 ++++++++++ internal/snapshot/dock.go | 24 ++++++++++++++++++++++++ internal/snapshot/dock_test.go | 11 +++++++++++ 3 files changed, 45 insertions(+) diff --git a/internal/snapshot/capture.go b/internal/snapshot/capture.go index 48a923c..e8ded59 100644 --- a/internal/snapshot/capture.go +++ b/internal/snapshot/capture.go @@ -38,6 +38,7 @@ type CaptureResults struct { Npm []string Bun []string Prefs []MacOSPref + DockApps []string Git *GitSnapshot Dotfiles *DotfilesSnapshot DevTools []DevTool @@ -81,6 +82,11 @@ var captureSteps = []captureStep{ r.Prefs = v return err }, func(r *CaptureResults) int { return len(r.Prefs) }}, + {"Dock Apps", func(r *CaptureResults) error { + v, err := CaptureDockApps() + r.DockApps = v + return err + }, func(r *CaptureResults) int { return len(r.DockApps) }}, {"Git Configuration", func(r *CaptureResults) error { v, err := CaptureGit() r.Git = v @@ -132,6 +138,9 @@ func assembleSnapshot(r *CaptureResults, failedSteps []string, hostname string) if r.Prefs == nil { r.Prefs = []MacOSPref{} } + if r.DockApps == nil { + r.DockApps = []string{} + } if r.Git == nil { r.Git = &GitSnapshot{} } @@ -157,6 +166,7 @@ func assembleSnapshot(r *CaptureResults, failedSteps []string, hostname string) Bun: r.Bun, }, MacOSPrefs: r.Prefs, + DockApps: r.DockApps, Shell: *r.Shell, Git: *r.Git, Dotfiles: *r.Dotfiles, diff --git a/internal/snapshot/dock.go b/internal/snapshot/dock.go index 5ab6004..06e27bd 100644 --- a/internal/snapshot/dock.go +++ b/internal/snapshot/dock.go @@ -5,8 +5,32 @@ import ( "fmt" "net/url" "strings" + + "github.com/openbootdotdev/openboot/internal/system" ) +// CaptureDockApps returns the user's currently pinned Dock apps in order. +// Returns ([]string{}, nil) when the Dock plist has no persistent-apps key. +func CaptureDockApps() ([]string, error) { + // `defaults export com.apple.dock -` writes the full plist as XML to + // stdout; piping to `plutil -extract persistent-apps json -o - -` + // returns just the array as JSON. We use sh -c to run the pipeline + // inside a single subprocess. + out, err := system.RunCommandOutput( + "sh", "-c", + `defaults export com.apple.dock - | plutil -extract persistent-apps json -o - -`, + ) + if err != nil { + // Treat as empty rather than fatal — keeps capture lossless + // when Dock has never been customized. + return []string{}, nil + } + if strings.TrimSpace(out) == "" { + return []string{}, nil + } + return parseDockAppsJSON([]byte(out)) +} + // dockTile is the subset of a persistent-apps entry we care about. type dockTile struct { TileType string `json:"tile-type"` diff --git a/internal/snapshot/dock_test.go b/internal/snapshot/dock_test.go index a31a72b..9d89f2c 100644 --- a/internal/snapshot/dock_test.go +++ b/internal/snapshot/dock_test.go @@ -49,3 +49,14 @@ func TestParseDockAppsJSON_MalformedJSON(t *testing.T) { _, err := parseDockAppsJSON([]byte(`not json`)) assert.Error(t, err) } + +// TestCaptureDockApps_NoPanic guards against panic regardless of whether +// the Dock plist exists / has entries on the host running tests. +func TestCaptureDockApps_NoPanic(t *testing.T) { + apps, err := CaptureDockApps() + // Returns ([]string{}, nil) when plutil/defaults output is empty; + // may return an error on hosts without a com.apple.dock domain. + // Either way, must not panic. + _ = apps + _ = err +} From b7b90b855514e7d6eaa3d9420d9938b1200d228d Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:23:55 +0800 Subject: [PATCH 04/15] test(snapshot): clarify CaptureDockApps no-panic comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment said the function "may return an error" — actually swallows them. --- internal/snapshot/dock_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/snapshot/dock_test.go b/internal/snapshot/dock_test.go index 9d89f2c..2d3a6f9 100644 --- a/internal/snapshot/dock_test.go +++ b/internal/snapshot/dock_test.go @@ -54,9 +54,9 @@ func TestParseDockAppsJSON_MalformedJSON(t *testing.T) { // the Dock plist exists / has entries on the host running tests. func TestCaptureDockApps_NoPanic(t *testing.T) { apps, err := CaptureDockApps() - // Returns ([]string{}, nil) when plutil/defaults output is empty; - // may return an error on hosts without a com.apple.dock domain. - // Either way, must not panic. + // CaptureDockApps swallows subprocess errors (returns ([]string{}, nil) + // in all failure paths) so capture stays lossless on virgin machines. + // We assert only that the call doesn't panic. _ = apps _ = err } From b261692ebbf5fe7985d1c7d178a0ce589c9e9926 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:25:31 +0800 Subject: [PATCH 05/15] feat(snapshot): add login items output parser Co-Authored-By: Claude Sonnet 4.6 --- internal/snapshot/loginitems.go | 32 +++++++++++++++++ internal/snapshot/loginitems_test.go | 54 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 internal/snapshot/loginitems.go create mode 100644 internal/snapshot/loginitems_test.go diff --git a/internal/snapshot/loginitems.go b/internal/snapshot/loginitems.go new file mode 100644 index 0000000..066ee5c --- /dev/null +++ b/internal/snapshot/loginitems.go @@ -0,0 +1,32 @@ +package snapshot + +import "strings" + +// parseLoginItemsOutput parses the tab-separated, linefeed-delimited rows +// emitted by the osascript wrapped in CaptureLoginItems. Columns: +// +// name \t path \t hidden(true|false) +// +// Rows with fewer than three columns are skipped silently — capture is +// best-effort. +func parseLoginItemsOutput(out string) ([]LoginItem, error) { + out = strings.ReplaceAll(out, "\r", "") + lines := strings.Split(out, "\n") + items := make([]LoginItem, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cols := strings.Split(line, "\t") + if len(cols) < 3 { + continue + } + items = append(items, LoginItem{ + Name: cols[0], + Path: cols[1], + Hidden: strings.EqualFold(strings.TrimSpace(cols[2]), "true"), + }) + } + return items, nil +} diff --git a/internal/snapshot/loginitems_test.go b/internal/snapshot/loginitems_test.go new file mode 100644 index 0000000..19f3c43 --- /dev/null +++ b/internal/snapshot/loginitems_test.go @@ -0,0 +1,54 @@ +package snapshot + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseLoginItemsOutput_Empty(t *testing.T) { + got, err := parseLoginItemsOutput("") + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestParseLoginItemsOutput_TwoItems(t *testing.T) { + input := "Maccy\t/Applications/Maccy.app\tfalse\n" + + "BetterDisplay\t/Applications/BetterDisplay.app\ttrue\n" + got, err := parseLoginItemsOutput(input) + require.NoError(t, err) + assert.Equal(t, []LoginItem{ + {Name: "Maccy", Path: "/Applications/Maccy.app", Hidden: false}, + {Name: "BetterDisplay", Path: "/Applications/BetterDisplay.app", Hidden: true}, + }, got) +} + +func TestParseLoginItemsOutput_TrailingWhitespace(t *testing.T) { + input := "Maccy\t/Applications/Maccy.app\tfalse\r\n\n" + got, err := parseLoginItemsOutput(input) + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "Maccy", got[0].Name) +} + +func TestParseLoginItemsOutput_NameWithSpaces(t *testing.T) { + input := "Scroll Reverser\t/Applications/Scroll Reverser.app\tfalse\n" + got, err := parseLoginItemsOutput(input) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "Scroll Reverser", got[0].Name) + assert.Equal(t, "/Applications/Scroll Reverser.app", got[0].Path) +} + +func TestParseLoginItemsOutput_MalformedRowSkipped(t *testing.T) { + input := "OK\t/Applications/OK.app\tfalse\n" + + "broken row no tabs\n" + + "Other\t/Applications/Other.app\ttrue\n" + got, err := parseLoginItemsOutput(input) + require.NoError(t, err) + assert.Equal(t, []LoginItem{ + {Name: "OK", Path: "/Applications/OK.app", Hidden: false}, + {Name: "Other", Path: "/Applications/Other.app", Hidden: true}, + }, got) +} From adc039f6c73df71fff538ac9889cf08a588cbe85 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:27:49 +0800 Subject: [PATCH 06/15] feat(snapshot): capture login items as first-class field Wire CaptureLoginItems (osascript via system.RunCommandOutput) into the capture pipeline immediately after the Dock Apps step; register LoginItems in CaptureResults with nil-guard and assembleSnapshot copy. Co-Authored-By: Claude Sonnet 4.6 --- internal/snapshot/capture.go | 14 +++++++++++-- internal/snapshot/loginitems.go | 30 +++++++++++++++++++++++++++- internal/snapshot/loginitems_test.go | 9 +++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/snapshot/capture.go b/internal/snapshot/capture.go index e8ded59..fde75ab 100644 --- a/internal/snapshot/capture.go +++ b/internal/snapshot/capture.go @@ -38,8 +38,9 @@ type CaptureResults struct { Npm []string Bun []string Prefs []MacOSPref - DockApps []string - Git *GitSnapshot + DockApps []string + LoginItems []LoginItem + Git *GitSnapshot Dotfiles *DotfilesSnapshot DevTools []DevTool Shell *ShellSnapshot @@ -87,6 +88,11 @@ var captureSteps = []captureStep{ r.DockApps = v return err }, func(r *CaptureResults) int { return len(r.DockApps) }}, + {"Login Items", func(r *CaptureResults) error { + v, err := CaptureLoginItems() + r.LoginItems = v + return err + }, func(r *CaptureResults) int { return len(r.LoginItems) }}, {"Git Configuration", func(r *CaptureResults) error { v, err := CaptureGit() r.Git = v @@ -141,6 +147,9 @@ func assembleSnapshot(r *CaptureResults, failedSteps []string, hostname string) if r.DockApps == nil { r.DockApps = []string{} } + if r.LoginItems == nil { + r.LoginItems = []LoginItem{} + } if r.Git == nil { r.Git = &GitSnapshot{} } @@ -167,6 +176,7 @@ func assembleSnapshot(r *CaptureResults, failedSteps []string, hostname string) }, MacOSPrefs: r.Prefs, DockApps: r.DockApps, + LoginItems: r.LoginItems, Shell: *r.Shell, Git: *r.Git, Dotfiles: *r.Dotfiles, diff --git a/internal/snapshot/loginitems.go b/internal/snapshot/loginitems.go index 066ee5c..942e4aa 100644 --- a/internal/snapshot/loginitems.go +++ b/internal/snapshot/loginitems.go @@ -1,6 +1,34 @@ package snapshot -import "strings" +import ( + "strings" + + "github.com/openbootdotdev/openboot/internal/system" +) + +// loginItemsScript is the osascript that emits one row per login item +// with name, path, and hidden separated by tab. End-of-row is linefeed. +// Using a separator-based format avoids parsing AppleScript records. +const loginItemsScript = `tell application "System Events" + set out to "" + repeat with li in login items + try + set out to out & (name of li) & tab & (path of li) & tab & (hidden of li) & linefeed + end try + end repeat + return out +end tell` + +// CaptureLoginItems returns the user's currently registered login items. +// Returns ([]LoginItem{}, nil) when none are registered or when System +// Events denies access — capture is best-effort. +func CaptureLoginItems() ([]LoginItem, error) { + out, err := system.RunCommandOutput("osascript", "-e", loginItemsScript) + if err != nil { + return []LoginItem{}, nil + } + return parseLoginItemsOutput(out) +} // parseLoginItemsOutput parses the tab-separated, linefeed-delimited rows // emitted by the osascript wrapped in CaptureLoginItems. Columns: diff --git a/internal/snapshot/loginitems_test.go b/internal/snapshot/loginitems_test.go index 19f3c43..afabd0b 100644 --- a/internal/snapshot/loginitems_test.go +++ b/internal/snapshot/loginitems_test.go @@ -52,3 +52,12 @@ func TestParseLoginItemsOutput_MalformedRowSkipped(t *testing.T) { {Name: "Other", Path: "/Applications/Other.app", Hidden: true}, }, got) } + +func TestCaptureLoginItems_NoPanic(t *testing.T) { + items, err := CaptureLoginItems() + // CaptureLoginItems swallows osascript errors (returns ([]LoginItem{}, nil) + // in all failure paths) — on CI hosts System Events may deny access. + // We assert only that the call doesn't panic. + _ = items + _ = err +} From f4bb92c56b420e46021e9d352f661f82c2df533d Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:32:47 +0800 Subject: [PATCH 07/15] chore(archtest): exempt dock.go and loginitems.go from dryrun rule Both are read-only system probes (defaults export, osascript reads) used during snapshot capture, mirroring the existing capture.go exemption. --- internal/archtest/dryrun_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/archtest/dryrun_test.go b/internal/archtest/dryrun_test.go index b23ad3f..b8aa191 100644 --- a/internal/archtest/dryrun_test.go +++ b/internal/archtest/dryrun_test.go @@ -24,9 +24,11 @@ var dryRunExemptPaths = []string{ // dryRunExemptFiles lists individual files exempt from the rule. var dryRunExemptFiles = []string{ - "internal/installer/state.go", // install state tracking - "internal/snapshot/capture.go", // read-only system probes (brew list, npm list, git config --get, etc.) - "internal/sync/diff.go", // read-only dotfiles remote probe for diff computation + "internal/installer/state.go", // install state tracking + "internal/snapshot/capture.go", // read-only system probes (brew list, npm list, git config --get, etc.) + "internal/snapshot/dock.go", // read-only: `defaults export | plutil` for Dock pinned apps + "internal/snapshot/loginitems.go", // read-only: osascript reads Login Items + "internal/sync/diff.go", // read-only dotfiles remote probe for diff computation } // destructiveOsCalls lists os package functions that modify the filesystem. From 5e4676214363687fc7154896afb70d2213a9b490 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:33:16 +0800 Subject: [PATCH 08/15] style(snapshot): gofmt CaptureResults struct alignment --- internal/snapshot/capture.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/snapshot/capture.go b/internal/snapshot/capture.go index fde75ab..63fb33c 100644 --- a/internal/snapshot/capture.go +++ b/internal/snapshot/capture.go @@ -32,18 +32,18 @@ type ScanStep struct { // populated one at a time by CaptureWithProgress and then read by // assembleSnapshot — no type assertions needed. type CaptureResults struct { - Formulae []string - Casks []string - Taps []string - Npm []string - Bun []string - Prefs []MacOSPref + Formulae []string + Casks []string + Taps []string + Npm []string + Bun []string + Prefs []MacOSPref DockApps []string LoginItems []LoginItem Git *GitSnapshot - Dotfiles *DotfilesSnapshot - DevTools []DevTool - Shell *ShellSnapshot + Dotfiles *DotfilesSnapshot + DevTools []DevTool + Shell *ShellSnapshot } type captureStep struct { From f6e403b4086f74863d74addcafb1c09bf9d588c4 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:35:56 +0800 Subject: [PATCH 09/15] feat(config): add DockApps and LoginItems to RemoteConfig Add LoginItem (parallel struct, no snapshot import to avoid import cycle) and DockApps/LoginItems fields to RemoteConfig so the CLI can decode configs from the openboot.dev API that include these new fields. Co-Authored-By: Claude Sonnet 4.6 --- internal/config/types.go | 12 +++++++++++ internal/config/types_extra_test.go | 33 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/config/types.go b/internal/config/types.go index 54f5207..b2c9319 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -123,6 +123,16 @@ func (p PackageEntryList) DescMap() map[string]string { return m } +// LoginItem mirrors snapshot.LoginItem for decoding RemoteConfig from the +// openboot.dev API. The two types are kept structurally identical and the +// snapshot package is not imported here because doing so would cycle +// (snapshot already imports config). +type LoginItem struct { + Name string `json:"name"` + Path string `json:"path"` + Hidden bool `json:"hidden,omitempty"` +} + type RemoteConfig struct { Username string `json:"username"` Slug string `json:"slug"` @@ -136,6 +146,8 @@ type RemoteConfig struct { PostInstall []string `json:"post_install"` Shell *RemoteShellConfig `json:"shell"` MacOSPrefs []RemoteMacOSPref `json:"macos_prefs"` + DockApps []string `json:"dock_apps,omitempty"` + LoginItems []LoginItem `json:"login_items,omitempty"` } type RemoteShellConfig struct { diff --git a/internal/config/types_extra_test.go b/internal/config/types_extra_test.go index 2c67022..2c2fbe8 100644 --- a/internal/config/types_extra_test.go +++ b/internal/config/types_extra_test.go @@ -212,3 +212,36 @@ func TestRemoteMacOSPref_Fields(t *testing.T) { assert.Equal(t, "true", p.Value) assert.Equal(t, "Auto-hide Dock", p.Desc) } + +// ---- RemoteConfig DockApps and LoginItems ---- + +func TestRemoteConfig_DecodeDockAndLoginItems(t *testing.T) { + raw := []byte(`{ + "username":"alice","slug":"dev","name":"Dev","preset":"developer", + "packages":[],"casks":[],"taps":[],"npm":[], + "dotfiles_repo":"","post_install":[], + "macos_prefs":[], + "dock_apps":["/Applications/Zed.app","/Applications/Chrome.app"], + "login_items":[ + {"name":"Maccy","path":"/Applications/Maccy.app"}, + {"name":"BetterDisplay","path":"/Applications/BetterDisplay.app","hidden":true} + ] + }`) + var rc RemoteConfig + require.NoError(t, json.Unmarshal(raw, &rc)) + assert.Equal(t, []string{"/Applications/Zed.app", "/Applications/Chrome.app"}, rc.DockApps) + require.Len(t, rc.LoginItems, 2) + assert.Equal(t, "Maccy", rc.LoginItems[0].Name) + assert.False(t, rc.LoginItems[0].Hidden) + assert.True(t, rc.LoginItems[1].Hidden) +} + +func TestRemoteConfig_OldConfigOmittingNewFieldsStillDecodes(t *testing.T) { + raw := []byte(`{"username":"a","slug":"b","name":"c","preset":"developer", + "packages":[],"casks":[],"taps":[],"npm":[],"dotfiles_repo":"", + "post_install":[],"macos_prefs":[]}`) + var rc RemoteConfig + require.NoError(t, json.Unmarshal(raw, &rc)) + assert.Nil(t, rc.DockApps) + assert.Nil(t, rc.LoginItems) +} From 0db6138c1ef0b4d1a2c41f26f022d45584f7c6ff Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:41:23 +0800 Subject: [PATCH 10/15] feat(macos): add SetDockApps with declarative-replace semantics Adds macos.SetDockApps(apps []string, dryRun bool) error which clears persistent-apps, re-adds each present app as a plist tile, and restarts the Dock. Missing apps are skipped with a stderr warning. Raw fmt.Print* calls are used instead of ui.* helpers because the macos package cannot import ui (import cycle via snapshot); new lines are baselined in internal/archtest/baseline/fmtprint.txt with a justification comment. Co-Authored-By: Claude Sonnet 4.6 --- internal/archtest/baseline/fmtprint.txt | 5 ++ internal/macos/dock.go | 63 ++++++++++++++++++++++++ internal/macos/dock_test.go | 65 +++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 internal/macos/dock.go create mode 100644 internal/macos/dock_test.go diff --git a/internal/archtest/baseline/fmtprint.txt b/internal/archtest/baseline/fmtprint.txt index 2ef19c7..186cde7 100644 --- a/internal/archtest/baseline/fmtprint.txt +++ b/internal/archtest/baseline/fmtprint.txt @@ -1,6 +1,11 @@ # Baseline for archtest rule "fmtprint". # Each line is : of a known existing violation. # Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/... +internal/macos/dock.go:19 +internal/macos/dock.go:20 +internal/macos/dock.go:23 +internal/macos/dock.go:26 +internal/macos/dock.go:28 internal/macos/macos.go:94 internal/macos/macos.go:141 internal/macos/macos.go:153 diff --git a/internal/macos/dock.go b/internal/macos/dock.go new file mode 100644 index 0000000..5ed8034 --- /dev/null +++ b/internal/macos/dock.go @@ -0,0 +1,63 @@ +package macos + +import ( + "fmt" + "os" + + "github.com/openbootdotdev/openboot/internal/system" +) + +// SetDockApps replaces the Dock's pinned-apps list with the given +// absolute paths in order. Missing apps are warned and skipped. After +// success the Dock is restarted via `killall Dock` so the change is +// visible without a logout. +// +// Empty input is treated as "explicitly clear the Dock" — the caller +// (installer) decides nil-vs-empty semantics. +func SetDockApps(apps []string, dryRun bool) error { + if dryRun { + fmt.Println("[DRY-RUN] Would clear and rebuild Dock pinned apps:") + fmt.Println("[DRY-RUN] defaults delete com.apple.dock persistent-apps") + for _, app := range apps { + if _, err := os.Stat(app); err != nil { + fmt.Printf("[DRY-RUN] (skip, not installed) %s\n", app) + continue + } + fmt.Printf("[DRY-RUN] defaults write com.apple.dock persistent-apps -array-add \n", app) + } + fmt.Println("[DRY-RUN] killall Dock") + return nil + } + + // Clear first so the result is fully declarative. + // Ignore error: key may not exist on a virgin machine. + _, _ = system.RunCommandSilent("defaults", "delete", "com.apple.dock", "persistent-apps") + + for _, app := range apps { + if _, err := os.Stat(app); err != nil { + fmt.Fprintf(os.Stderr, "⚠ Dock: skipping %s (not installed)\n", app) + continue + } + tile := dockTileFor(app) + if _, err := system.RunCommandSilent("defaults", "write", + "com.apple.dock", "persistent-apps", "-array-add", tile); err != nil { + return fmt.Errorf("dock add %s: %w", app, err) + } + } + + // killall is best-effort: Dock may not be running on some CI hosts. + _, _ = system.RunCommandSilent("killall", "Dock") + return nil +} + +// dockTileFor returns the plist-XML blob that `defaults write +// ... -array-add` expects for one app tile. +func dockTileFor(appPath string) string { + return fmt.Sprintf( + `tile-datafile-data`+ + `_CFURLString%s`+ + `_CFURLStringType0`+ + ``, + appPath, + ) +} diff --git a/internal/macos/dock_test.go b/internal/macos/dock_test.go new file mode 100644 index 0000000..650b895 --- /dev/null +++ b/internal/macos/dock_test.go @@ -0,0 +1,65 @@ +package macos + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetDockApps_DryRunDeleteThenAddThenKillall(t *testing.T) { + out := captureStdout(t, func() { + err := SetDockApps([]string{ + "/Applications/Chrome.app", + "/Applications/Zed.app", + }, true /* dryRun */) + assert.NoError(t, err) + }) + deleteIdx := strings.Index(out, "defaults delete com.apple.dock persistent-apps") + chromeIdx := strings.Index(out, "Chrome.app") + zedIdx := strings.Index(out, "Zed.app") + killIdx := strings.Index(out, "killall Dock") + assert.NotEqual(t, -1, deleteIdx, "missing delete step") + assert.NotEqual(t, -1, chromeIdx, "missing chrome add") + assert.NotEqual(t, -1, zedIdx, "missing zed add") + assert.NotEqual(t, -1, killIdx, "missing killall Dock") + assert.Less(t, deleteIdx, chromeIdx, "delete must come before adds") + assert.Less(t, chromeIdx, zedIdx, "chrome (first in list) must come before zed") + assert.Less(t, zedIdx, killIdx, "killall must come after adds") +} + +func TestSetDockApps_DryRunEmptyClearsAndExits(t *testing.T) { + out := captureStdout(t, func() { + err := SetDockApps([]string{}, true) + assert.NoError(t, err) + }) + assert.Contains(t, out, "defaults delete com.apple.dock persistent-apps") + assert.NotContains(t, out, "-array-add") +} + +func TestSetDockApps_DryRunMissingAppPathSkippedWithWarn(t *testing.T) { + // Create a real .app directory so os.Stat succeeds for the "present" app. + dir := t.TempDir() + realApp := dir + "/Real.app" + if err := os.MkdirAll(realApp, 0755); err != nil { + t.Fatal(err) + } + + out := captureStdout(t, func() { + err := SetDockApps([]string{ + "/Applications/DefinitelyDoesNotExist123.app", + realApp, + }, true) + assert.NoError(t, err) + }) + assert.Contains(t, out, "DefinitelyDoesNotExist123.app") + assert.Contains(t, out, "Real.app") + addLines := 0 + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "-array-add") { + addLines++ + } + } + assert.Equal(t, 1, addLines, "only Real.app should be added") +} From 1bb5198ee1f73929aae97f00141a3f71ebaf2447 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:47:54 +0800 Subject: [PATCH 11/15] feat(macos): add SetLoginItems with declarative-replace semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements SetLoginItems(items []LoginItem, dryRun bool) error in the macos package. Defines a local LoginItem type to avoid the snapshot→macos import cycle. Dry-run output uses raw fmt.Print* (same pattern as SetDockApps); new violations baselined in fmtprint.txt. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/archtest/baseline/fmtprint.txt | 3 + internal/macos/loginitems.go | 89 +++++++++++++++++++++++++ internal/macos/loginitems_test.go | 63 +++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 internal/macos/loginitems.go create mode 100644 internal/macos/loginitems_test.go diff --git a/internal/archtest/baseline/fmtprint.txt b/internal/archtest/baseline/fmtprint.txt index 186cde7..fb3bdb6 100644 --- a/internal/archtest/baseline/fmtprint.txt +++ b/internal/archtest/baseline/fmtprint.txt @@ -6,6 +6,9 @@ internal/macos/dock.go:20 internal/macos/dock.go:23 internal/macos/dock.go:26 internal/macos/dock.go:28 +internal/macos/loginitems.go:33 +internal/macos/loginitems.go:45 +internal/macos/loginitems.go:47 internal/macos/macos.go:94 internal/macos/macos.go:141 internal/macos/macos.go:153 diff --git a/internal/macos/loginitems.go b/internal/macos/loginitems.go new file mode 100644 index 0000000..2ec1626 --- /dev/null +++ b/internal/macos/loginitems.go @@ -0,0 +1,89 @@ +package macos + +import ( + "fmt" + "os" + "strings" + + "github.com/openbootdotdev/openboot/internal/system" +) + +// LoginItem represents one entry in the user's Login Items list. +// Name is the System Events identifier; Path is the absolute path to the +// .app bundle; Hidden controls whether the app launches hidden. +type LoginItem struct { + Name string + Path string + Hidden bool +} + +// SetLoginItems replaces the user's Login Items list with the given +// items, in order. Declarative-replace semantics: the leading "delete +// every login item" pass removes anything present on the system that +// isn't in the input, then each input item is recreated (so existing +// items with matching names are reset to the new path/hidden state). +// +// Items whose Path does not exist on disk are warned and skipped. +func SetLoginItems(items []LoginItem, dryRun bool) error { + // Filter unavailable paths up-front so the osascript stays small. + filtered := make([]LoginItem, 0, len(items)) + for _, it := range items { + if _, err := os.Stat(it.Path); err != nil { + if dryRun { + fmt.Printf("[DRY-RUN] Login items: skip %s (not installed at %s)\n", it.Name, it.Path) + } else { + fmt.Fprintf(os.Stderr, "⚠ Login items: skipping %s (not installed at %s)\n", it.Name, it.Path) + } + continue + } + filtered = append(filtered, it) + } + + script := loginItemsApplyScript(filtered) + + if dryRun { + fmt.Println("[DRY-RUN] Would run osascript to replace login items:") + for _, line := range strings.Split(script, "\n") { + fmt.Printf("[DRY-RUN] %s\n", line) + } + return nil + } + + if _, err := system.RunCommandSilent("osascript", "-e", script); err != nil { + return fmt.Errorf("set login items: %w", err) + } + return nil +} + +// loginItemsApplyScript returns the osascript that: +// 1. Deletes every existing login item (declarative wipe). +// 2. For each input item, creates a new login item. +// +// Item names and paths are escaped for embedding in a quoted +// AppleScript string by doubling backslashes and escaping double +// quotes per AppleScript convention. +func loginItemsApplyScript(items []LoginItem) string { + var b strings.Builder + b.WriteString(`tell application "System Events"` + "\n") + b.WriteString("\ttry\n") + b.WriteString("\t\tdelete every login item\n") + b.WriteString("\tend try\n") + for _, it := range items { + fmt.Fprintf(&b, + "\tmake new login item with properties {name:\"%s\", path:\"%s\", hidden:%t}\n", + escapeAppleScriptString(it.Name), + escapeAppleScriptString(it.Path), + it.Hidden, + ) + } + b.WriteString("end tell") + return b.String() +} + +// escapeAppleScriptString escapes double-quotes and backslashes for +// embedding in an AppleScript double-quoted literal. +func escapeAppleScriptString(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} diff --git a/internal/macos/loginitems_test.go b/internal/macos/loginitems_test.go new file mode 100644 index 0000000..2811722 --- /dev/null +++ b/internal/macos/loginitems_test.go @@ -0,0 +1,63 @@ +package macos + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// makeFakeApp creates an empty directory pretending to be an .app bundle. +// Returns the absolute path. +func makeFakeApp(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir fake app: %v", err) + } + return dir +} + +func TestSetLoginItems_DryRunDeleteThenMake(t *testing.T) { + maccyPath := makeFakeApp(t, "Maccy.app") + bdPath := makeFakeApp(t, "BetterDisplay.app") + out := captureStdout(t, func() { + err := SetLoginItems([]LoginItem{ + {Name: "Maccy", Path: maccyPath}, + {Name: "BetterDisplay", Path: bdPath, Hidden: true}, + }, true /* dryRun */) + assert.NoError(t, err) + }) + assert.Contains(t, out, "delete every login item") + assert.Contains(t, out, "make new login item") + assert.Contains(t, out, "Maccy") + assert.Contains(t, out, "BetterDisplay") + assert.True(t, + strings.Contains(out, "hidden:true") || strings.Contains(out, "hidden: true"), + "hidden flag missing from dry-run output: %s", out) +} + +func TestSetLoginItems_DryRunEmptyClears(t *testing.T) { + out := captureStdout(t, func() { + err := SetLoginItems([]LoginItem{}, true) + assert.NoError(t, err) + }) + assert.Contains(t, out, "delete every login item") +} + +func TestSetLoginItems_DryRunMissingPathSkipped(t *testing.T) { + calcPath := makeFakeApp(t, "Calculator.app") + out := captureStdout(t, func() { + err := SetLoginItems([]LoginItem{ + {Name: "Ghost", Path: "/Applications/Ghost123Nope.app"}, + {Name: "Calculator", Path: calcPath}, + }, true) + assert.NoError(t, err) + }) + assert.Contains(t, out, "Ghost123Nope.app") + assert.Contains(t, out, "Calculator.app") + makeCount := strings.Count(out, "make new login item") + assert.Equal(t, 1, makeCount, "only Calculator should be created") +} From cad482859363d6b81abbdc8ddb8c2a6d6395b3d6 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:52:28 +0800 Subject: [PATCH 12/15] feat(installer): carry DockApps and LoginItems through InstallPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DockApps []string and LoginItems []macos.LoginItem to InstallPlan and map them from RemoteConfig in planFromRemoteConfig, mirroring the existing MacOSPrefs pattern. PlanFromSnapshot is left unchanged — InstallState has no SnapshotDockApps/SnapshotLoginItems fields yet (Task 1 only added them to snapshot.Snapshot and config.RemoteConfig, not InstallState). Co-Authored-By: Claude Sonnet 4.6 --- internal/installer/plan.go | 12 ++++++++++++ internal/installer/plan_test.go | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/installer/plan.go b/internal/installer/plan.go index 8f93583..d772007 100644 --- a/internal/installer/plan.go +++ b/internal/installer/plan.go @@ -42,6 +42,8 @@ type InstallPlan struct { // macOS MacOSPrefs []macos.Preference + DockApps []string + LoginItems []macos.LoginItem // Post-install PostInstall []string @@ -111,6 +113,16 @@ func planFromRemoteConfig(opts *config.InstallOptions, st *config.InstallState, }) } + plan.DockApps = rc.DockApps + + for _, li := range rc.LoginItems { + plan.LoginItems = append(plan.LoginItems, macos.LoginItem{ + Name: li.Name, + Path: li.Path, + Hidden: li.Hidden, + }) + } + plan.PostInstall = rc.PostInstall plan.SelectedPkgs = make(map[string]bool) diff --git a/internal/installer/plan_test.go b/internal/installer/plan_test.go index 4f94efe..eae28b5 100644 --- a/internal/installer/plan_test.go +++ b/internal/installer/plan_test.go @@ -507,6 +507,29 @@ func TestPlanFromSnapshot_MacOSSkipFlag(t *testing.T) { assert.Nil(t, plan.MacOSPrefs, "--macos skip must prevent prefs restore") } +func TestPlan_CarriesDockAppsAndLoginItems(t *testing.T) { + rc := &config.RemoteConfig{ + Username: "alice", Slug: "dev", Preset: "developer", + DockApps: []string{"/Applications/Chrome.app"}, + LoginItems: []config.LoginItem{ + {Name: "Maccy", Path: "/Applications/Maccy.app"}, + {Name: "BetterDisplay", Path: "/Applications/BetterDisplay.app", Hidden: true}, + }, + } + st := &config.InstallState{RemoteConfig: rc} + opts := &config.InstallOptions{DryRun: true, Silent: true} + plan, err := Plan(opts, st) + require.NoError(t, err) + assert.Equal(t, []string{"/Applications/Chrome.app"}, plan.DockApps) + require.Len(t, plan.LoginItems, 2) + assert.Equal(t, "Maccy", plan.LoginItems[0].Name) + assert.Equal(t, "/Applications/Maccy.app", plan.LoginItems[0].Path) + assert.False(t, plan.LoginItems[0].Hidden) + assert.Equal(t, "BetterDisplay", plan.LoginItems[1].Name) + assert.Equal(t, "/Applications/BetterDisplay.app", plan.LoginItems[1].Path) + assert.True(t, plan.LoginItems[1].Hidden) +} + func TestPlanFromSnapshot_PackageCategorizationFromSelectedPkgs(t *testing.T) { cfg := &config.Config{ InstallOptions: config.InstallOptions{ From f51c3ffc631fe019b0a57e313c377bf436266cb2 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 20:57:33 +0800 Subject: [PATCH 13/15] feat(installer): apply Dock and Login Items as macOS-step subtasks Wire macos.SetDockApps and macos.SetLoginItems into applyMacOSPrefs, restructuring it into three subtasks (defaults, dock, login items) with errors.Join aggregation; nil fields still short-circuit their subtask. Co-Authored-By: Claude Sonnet 4.6 --- internal/installer/step_system.go | 51 +++++++++++++++++++++++++- internal/installer/step_system_test.go | 45 +++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/internal/installer/step_system.go b/internal/installer/step_system.go index 4015648..dbef60e 100644 --- a/internal/installer/step_system.go +++ b/internal/installer/step_system.go @@ -1,6 +1,7 @@ package installer import ( + "errors" "fmt" "os" "os/exec" @@ -12,19 +13,43 @@ import ( ) func applyMacOSPrefs(plan InstallPlan, r Reporter) error { - if len(plan.MacOSPrefs) == 0 { + hasPrefs := len(plan.MacOSPrefs) > 0 + hasDock := plan.DockApps != nil + hasLogin := plan.LoginItems != nil + if !hasPrefs && !hasDock && !hasLogin { return nil } r.Header("Step 7: macOS Preferences") ui.Println() + var errs []error + + if hasPrefs { + if err := applyMacOSDefaultsSubtask(plan, r); err != nil { + errs = append(errs, fmt.Errorf("defaults: %w", err)) + } + } + if hasDock { + if err := applyDockSubtask(plan, r); err != nil { + errs = append(errs, fmt.Errorf("dock: %w", err)) + } + } + if hasLogin { + if err := applyLoginItemsSubtask(plan, r); err != nil { + errs = append(errs, fmt.Errorf("login items: %w", err)) + } + } + + return errors.Join(errs...) +} + +func applyMacOSDefaultsSubtask(plan InstallPlan, r Reporter) error { if plan.DryRun { ui.DryRunMsg("Would apply %d macOS preferences", len(plan.MacOSPrefs)) ui.Println() return nil } - if err := macos.CreateScreenshotsDir(false); err != nil { r.Error(fmt.Sprintf("Failed to create Screenshots dir: %v", err)) } @@ -39,6 +64,28 @@ func applyMacOSPrefs(plan InstallPlan, r Reporter) error { return nil } +func applyDockSubtask(plan InstallPlan, r Reporter) error { + if err := macos.SetDockApps(plan.DockApps, plan.DryRun); err != nil { + return err + } + if !plan.DryRun { + r.Success(fmt.Sprintf("Dock configured (%d apps pinned)", len(plan.DockApps))) + } + ui.Println() + return nil +} + +func applyLoginItemsSubtask(plan InstallPlan, r Reporter) error { + if err := macos.SetLoginItems(plan.LoginItems, plan.DryRun); err != nil { + return err + } + if !plan.DryRun { + r.Success(fmt.Sprintf("Login items configured (%d items)", len(plan.LoginItems))) + } + ui.Println() + return nil +} + func applyPostInstall(plan InstallPlan, r Reporter) error { if len(plan.PostInstall) == 0 { return nil diff --git a/internal/installer/step_system_test.go b/internal/installer/step_system_test.go index dd88ed3..5206521 100644 --- a/internal/installer/step_system_test.go +++ b/internal/installer/step_system_test.go @@ -345,3 +345,48 @@ func TestApplyDotfiles_DryRun_WithURL(t *testing.T) { err := applyDotfiles(plan, NopReporter{}) assert.NoError(t, err) } + +func TestApplyMacOSPrefs_NilFieldsSkipSubtasks(t *testing.T) { + // Per spec: missing fields (nil) = skip subtasks; with all three nil/empty, + // the function early-returns nil without any output or error. + plan := InstallPlan{DryRun: true} + err := applyMacOSPrefs(plan, NopReporter{}) + assert.NoError(t, err) +} + +func TestApplyMacOSPrefs_DryRunRunsDockSubtask(t *testing.T) { + plan := InstallPlan{ + DryRun: true, + DockApps: []string{"/Applications/Calculator.app"}, + } + err := applyMacOSPrefs(plan, NopReporter{}) + assert.NoError(t, err) +} + +func TestApplyMacOSPrefs_DryRunRunsLoginItemsSubtask(t *testing.T) { + plan := InstallPlan{ + DryRun: true, + LoginItems: []macos.LoginItem{ + {Name: "Calculator", Path: "/Applications/Calculator.app"}, + }, + } + err := applyMacOSPrefs(plan, NopReporter{}) + assert.NoError(t, err) +} + +func TestApplyMacOSPrefs_DryRunAllThreeSubtasks(t *testing.T) { + // All three subtasks together in dry-run mode — verifies orchestration + // runs each and aggregates without error. + plan := InstallPlan{ + DryRun: true, + MacOSPrefs: []macos.Preference{ + {Domain: "com.apple.dock", Key: "tilesize", Type: "int", Value: "48"}, + }, + DockApps: []string{"/Applications/Calculator.app"}, + LoginItems: []macos.LoginItem{ + {Name: "Calculator", Path: "/Applications/Calculator.app"}, + }, + } + err := applyMacOSPrefs(plan, NopReporter{}) + assert.NoError(t, err) +} From 2aec000cccada78e3b1ce3c236357c96afc32743 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 21:02:04 +0800 Subject: [PATCH 14/15] chore(archtest): rebaseline no-direct-exec after step_system.go restructure Task 10's restructure of applyMacOSPrefs shifted the pre-existing exec.Command in applyPostInstall from line 85 to line 132. Same call, no new violations. --- internal/archtest/baseline/no-direct-exec.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index 919b3b9..e573f3a 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -11,7 +11,7 @@ internal/dotfiles/dotfiles.go:32 internal/dotfiles/dotfiles.go:66 internal/dotfiles/dotfiles.go:351 internal/dotfiles/dotfiles.go:449 -internal/installer/step_system.go:85 +internal/installer/step_system.go:132 internal/npm/npm.go:22 internal/permissions/screen_recording_cgo.go:21 internal/shell/shell.go:178 From 52fcfbbc80a060388dc398486719a91813c947ad Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Mon, 8 Jun 2026 21:22:33 +0800 Subject: [PATCH 15/15] fix(snapshot): parse Dock plist as XML to handle binary data blobs defaults export | plutil -extract persistent-apps json fails on real Dock plists because tile-data contains blobs (alias bookmarks, icon thumbnails). Switch to xml1 + stdlib encoding/xml token walker so capture works on actual machines, not just synthetic fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/snapshot/dock.go | 237 +++++++++++++++++++++++++++++---- internal/snapshot/dock_test.go | 162 +++++++++++++++++++--- 2 files changed, 351 insertions(+), 48 deletions(-) diff --git a/internal/snapshot/dock.go b/internal/snapshot/dock.go index 06e27bd..58fc210 100644 --- a/internal/snapshot/dock.go +++ b/internal/snapshot/dock.go @@ -1,7 +1,7 @@ package snapshot import ( - "encoding/json" + "encoding/xml" "fmt" "net/url" "strings" @@ -12,13 +12,14 @@ import ( // CaptureDockApps returns the user's currently pinned Dock apps in order. // Returns ([]string{}, nil) when the Dock plist has no persistent-apps key. func CaptureDockApps() ([]string, error) { - // `defaults export com.apple.dock -` writes the full plist as XML to - // stdout; piping to `plutil -extract persistent-apps json -o - -` - // returns just the array as JSON. We use sh -c to run the pipeline - // inside a single subprocess. + // `defaults export com.apple.dock -` writes the full Dock plist as XML to + // stdout; piping to `plutil -extract persistent-apps xml1 -o - -` returns + // just the persistent-apps array as plist XML. We use xml1 (not json) + // because tile-data contains blobs (alias bookmarks, icon + // thumbnails) that plutil refuses to serialise as JSON. out, err := system.RunCommandOutput( "sh", "-c", - `defaults export com.apple.dock - | plutil -extract persistent-apps json -o - -`, + `defaults export com.apple.dock - | plutil -extract persistent-apps xml1 -o - -`, ) if err != nil { // Treat as empty rather than fatal — keeps capture lossless @@ -28,33 +29,36 @@ func CaptureDockApps() ([]string, error) { if strings.TrimSpace(out) == "" { return []string{}, nil } - return parseDockAppsJSON([]byte(out)) + return parseDockAppsXML([]byte(out)) } -// dockTile is the subset of a persistent-apps entry we care about. -type dockTile struct { - TileType string `json:"tile-type"` - TileData struct { - FileData struct { - CFURLString string `json:"_CFURLString"` - } `json:"file-data"` - } `json:"tile-data"` -} - -// parseDockAppsJSON extracts absolute app paths from the JSON output of -// `defaults export com.apple.dock - | plutil -extract persistent-apps json -o - -`. -// Non-app tiles (folders, stacks, spacers) are skipped per spec. -func parseDockAppsJSON(data []byte) ([]string, error) { - var tiles []dockTile - if err := json.Unmarshal(data, &tiles); err != nil { - return nil, fmt.Errorf("parse dock plist json: %w", err) +// parseDockAppsXML extracts absolute app paths from the plist XML output of +// +// defaults export com.apple.dock - | plutil -extract persistent-apps xml1 -o - - +// +// Non-app tiles (folders, stacks, spacers) are skipped. +// blobs inside tile-data are silently ignored (regression guard). +func parseDockAppsXML(data []byte) ([]string, error) { + tiles, err := parsePlistArray(data) + if err != nil { + return nil, fmt.Errorf("parse dock plist xml: %w", err) } + apps := make([]string, 0, len(tiles)) - for _, t := range tiles { - if t.TileType != "file-tile" { + for _, tile := range tiles { + tileType, _ := tile["tile-type"].(string) + if tileType != "file-tile" { + continue + } + tileData, _ := tile["tile-data"].(plistDict) + if tileData == nil { + continue + } + fileData, _ := tileData["file-data"].(plistDict) + if fileData == nil { continue } - raw := t.TileData.FileData.CFURLString + raw, _ := fileData["_CFURLString"].(string) if raw == "" { continue } @@ -67,6 +71,185 @@ func parseDockAppsJSON(data []byte) ([]string, error) { return apps, nil } +// plistDict is a map representation of a element. +type plistDict = map[string]any + +// parsePlistArray parses a plist XML document whose root element is +// of entries and returns them as a slice of plistDict. +func parsePlistArray(data []byte) ([]plistDict, error) { + dec := xml.NewDecoder(strings.NewReader(string(data))) + // Advance past the XML declaration, DOCTYPE, and wrapper to reach + // the element. + for { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("reading plist: %w", err) + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "array" { + break + } + } + return readArray(dec) +} + +// readArray reads the contents of an already-opened element and +// returns each child as a plistDict. Non-dict children are skipped. +func readArray(dec *xml.Decoder) ([]plistDict, error) { + var result []plistDict + for { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("reading array: %w", err) + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local == "dict" { + d, err := readDict(dec) + if err != nil { + return nil, err + } + result = append(result, d) + } else { + // Skip any non-dict child (shouldn't appear in dock plist, but be safe). + if err := dec.Skip(); err != nil { + return nil, fmt.Errorf("skipping element: %w", err) + } + } + case xml.EndElement: + // + return result, nil + } + } +} + +// readDict reads the contents of an already-opened element and +// returns it as a plistDict. Values may be strings, integers, dicts, arrays, +// booleans, or data blobs. Data blobs are stored as a sentinel non-nil value +// so callers can detect their presence without caring about the bytes. +func readDict(dec *xml.Decoder) (plistDict, error) { + d := make(plistDict) + for { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("reading dict: %w", err) + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Local != "key" { + return nil, fmt.Errorf("expected , got <%s>", t.Name.Local) + } + key, err := readCharData(dec) + if err != nil { + return nil, fmt.Errorf("reading key: %w", err) + } + val, err := readValue(dec) + if err != nil { + return nil, fmt.Errorf("reading value for key %q: %w", key, err) + } + d[key] = val + case xml.EndElement: + // + return d, nil + } + } +} + +// readValue reads the next start element (the value following a ) and +// returns a Go representation. The element and its children are consumed. +func readValue(dec *xml.Decoder) (any, error) { + // Skip CharData (whitespace) before the value element. + for { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("reading value token: %w", err) + } + switch t := tok.(type) { + case xml.CharData: + continue + case xml.StartElement: + return readValueElement(dec, t) + case xml.EndElement: + return nil, fmt.Errorf("unexpected end element <%s> while reading value", t.Name.Local) + } + } +} + +// readValueElement reads a value given its opening StartElement (already consumed). +func readValueElement(dec *xml.Decoder, se xml.StartElement) (any, error) { + switch se.Name.Local { + case "string", "integer", "real": + return readCharData(dec) + case "true": + if err := expectEnd(dec, "true"); err != nil { + return nil, err + } + return true, nil + case "false": + if err := expectEnd(dec, "false"); err != nil { + return nil, err + } + return false, nil + case "data": + // Consume and discard — we don't need the bytes. + if err := dec.Skip(); err != nil { + // dec.Skip() re-consumes including end element, but we already + // consumed the start element. On error just return a sentinel. + return "", nil + } + // dec.Skip() consumed everything up to and including . + return "", nil + case "dict": + return readDict(dec) + case "array": + arr, err := readArray(dec) + if err != nil { + return nil, err + } + // Convert []plistDict to []any for uniform storage. + result := make([]any, len(arr)) + for i, d := range arr { + result[i] = d + } + return result, nil + default: + // Unknown element — skip it to stay robust. + if err := dec.Skip(); err != nil { + return nil, fmt.Errorf("skipping <%s>: %w", se.Name.Local, err) + } + return nil, nil + } +} + +// readCharData reads character data up to the next end element and returns +// it as a string. The end element is consumed. +func readCharData(dec *xml.Decoder) (string, error) { + var buf strings.Builder + for { + tok, err := dec.Token() + if err != nil { + return "", fmt.Errorf("reading char data: %w", err) + } + switch t := tok.(type) { + case xml.CharData: + buf.Write(t) + case xml.EndElement: + return buf.String(), nil + } + } +} + +// expectEnd consumes tokens until the matching end element for name is found. +func expectEnd(dec *xml.Decoder, name string) error { + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("expected : %w", name, err) + } + if ee, ok := tok.(xml.EndElement); ok && ee.Name.Local == name { + return nil + } + return fmt.Errorf("expected , got %T", name, tok) +} + // dockURLToPath converts a `file:///Applications/Foo.app/` URL into the // filesystem path `/Applications/Foo.app`. func dockURLToPath(raw string) (string, error) { diff --git a/internal/snapshot/dock_test.go b/internal/snapshot/dock_test.go index 2d3a6f9..2a0d160 100644 --- a/internal/snapshot/dock_test.go +++ b/internal/snapshot/dock_test.go @@ -7,18 +7,65 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseDockAppsJSON_Empty(t *testing.T) { - got, err := parseDockAppsJSON([]byte(`[]`)) +// plistHeader is the standard plist XML header used in fixtures below. +const plistHeader = ` + + +` + +func TestParseDockAppsXML_Empty(t *testing.T) { + input := plistHeader + ` + +` + got, err := parseDockAppsXML([]byte(input)) require.NoError(t, err) assert.Empty(t, got) } -func TestParseDockAppsJSON_TwoApps(t *testing.T) { - input := []byte(`[ - {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/Google%20Chrome.app/","_CFURLStringType":15}},"tile-type":"file-tile"}, - {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/Zed.app/","_CFURLStringType":15}},"tile-type":"file-tile"} - ]`) - got, err := parseDockAppsJSON(input) +// TestParseDockAppsXML_TwoApps also serves as the -blob regression +// guard: the "book" key inside tile-data contains a element (a real +// alias bookmark), which previously caused `plutil -extract ... json` to fail +// with "Invalid object in plist for JSON format". The xml1 parser must return +// both app paths without error regardless of the blob. +func TestParseDockAppsXML_TwoApps(t *testing.T) { + input := plistHeader + ` + + GUID + 4290584577 + tile-data + + file-data + + _CFURLString + file:///Applications/Google%20Chrome.app/ + _CFURLStringType + 15 + + book + Ym9va0QCAAAAAA== + bundle-identifier + com.google.Chrome + + tile-type + file-tile + + + tile-data + + file-data + + _CFURLString + file:///Applications/Zed.app/ + _CFURLStringType + 15 + + + tile-type + file-tile + + +` + got, err := parseDockAppsXML([]byte(input)) require.NoError(t, err) assert.Equal(t, []string{ "/Applications/Google Chrome.app", @@ -26,27 +73,100 @@ func TestParseDockAppsJSON_TwoApps(t *testing.T) { }, got) } -func TestParseDockAppsJSON_NonAppTileSkipped(t *testing.T) { - input := []byte(`[ - {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/Zed.app/","_CFURLStringType":15}},"tile-type":"file-tile"}, - {"tile-data":{"arrangement":2,"displayas":1},"tile-type":"directory-tile"} - ]`) - got, err := parseDockAppsJSON(input) +func TestParseDockAppsXML_NonAppTileSkipped(t *testing.T) { + input := plistHeader + ` + + tile-data + + file-data + + _CFURLString + file:///Applications/Zed.app/ + _CFURLStringType + 15 + + + tile-type + file-tile + + + tile-data + + arrangement + 2 + displayas + 1 + + tile-type + directory-tile + + +` + got, err := parseDockAppsXML([]byte(input)) require.NoError(t, err) assert.Equal(t, []string{"/Applications/Zed.app"}, got) } -func TestParseDockAppsJSON_NonASCIIPath(t *testing.T) { - input := []byte(`[ - {"tile-data":{"file-data":{"_CFURLString":"file:///Applications/%E5%BE%AE%E4%BF%A1.app/","_CFURLStringType":15}},"tile-type":"file-tile"} - ]`) - got, err := parseDockAppsJSON(input) +func TestParseDockAppsXML_NonASCIIPath(t *testing.T) { + input := plistHeader + ` + + tile-data + + file-data + + _CFURLString + file:///Applications/%E5%BE%AE%E4%BF%A1.app/ + _CFURLStringType + 15 + + + tile-type + file-tile + + +` + got, err := parseDockAppsXML([]byte(input)) require.NoError(t, err) assert.Equal(t, []string{"/Applications/微信.app"}, got) } -func TestParseDockAppsJSON_MalformedJSON(t *testing.T) { - _, err := parseDockAppsJSON([]byte(`not json`)) +func TestParseDockAppsXML_DataBlobRegression(t *testing.T) { + // Regression guard: a blob anywhere inside tile-data must not + // break parsing. Previously, plutil's json output path failed with + // "Invalid object in plist for JSON format" when such blobs existed, + // causing CaptureDockApps to silently return an empty slice. + // This test is the canonical proof that the xml1 path handles it. + input := plistHeader + ` + + tile-data + + file-data + + _CFURLString + file:///Applications/Ghostty.app/ + _CFURLStringType + 15 + + book + + Ym9va0QCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + dock-extra + + + tile-type + file-tile + + +` + got, err := parseDockAppsXML([]byte(input)) + require.NoError(t, err) + assert.Equal(t, []string{"/Applications/Ghostty.app"}, got) +} + +func TestParseDockAppsXML_MalformedXML(t *testing.T) { + _, err := parseDockAppsXML([]byte(`not xml at all`)) assert.Error(t, err) }