Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/archtest/baseline/fmtprint.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Baseline for archtest rule "fmtprint".
# Each line is <file>:<line> 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/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
2 changes: 1 addition & 1 deletion internal/archtest/baseline/no-direct-exec.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions internal/archtest/dryrun_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions internal/config/types_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
12 changes: 12 additions & 0 deletions internal/installer/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type InstallPlan struct {

// macOS
MacOSPrefs []macos.Preference
DockApps []string
LoginItems []macos.LoginItem

// Post-install
PostInstall []string
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions internal/installer/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
51 changes: 49 additions & 2 deletions internal/installer/step_system.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package installer

import (
"errors"
"fmt"
"os"
"os/exec"
Expand All @@ -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))
}
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions internal/installer/step_system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
63 changes: 63 additions & 0 deletions internal/macos/dock.go
Original file line number Diff line number Diff line change
@@ -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 <tile for %s>\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 <dict> blob that `defaults write
// ... -array-add` expects for one app tile.
func dockTileFor(appPath string) string {
return fmt.Sprintf(
`<dict><key>tile-data</key><dict><key>file-data</key><dict>`+
`<key>_CFURLString</key><string>%s</string>`+
`<key>_CFURLStringType</key><integer>0</integer>`+
`</dict></dict></dict>`,
appPath,
)
}
Loading
Loading