Skip to content

Commit 1d8acfc

Browse files
committed
feat: interactive TUI selector for macOS preferences
Replace the yes/no prompt in Step 7 with a full category-browsing TUI, matching the package selector UX. Users can tab across 7 categories (System, Finder, Dock, Screenshots, Safari, TextEdit, TimeMachine), toggle individual prefs with Space, and review a summary before applying. All prefs start pre-selected; --macos configure and silent/non-TTY modes still apply defaults directly without launching the TUI. DefaultPreferences is now derived from DefaultCategories as the single source of truth, eliminating the dual-maintenance risk.
1 parent e58bb27 commit 1d8acfc

File tree

7 files changed

+1141
-51
lines changed

7 files changed

+1141
-51
lines changed

internal/installer/installer.go

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -680,36 +680,48 @@ func stepMacOS(cfg *config.Config) error {
680680
ui.Header("Step 7: macOS Preferences")
681681
fmt.Println()
682682

683-
if cfg.Macos == "" {
684-
if cfg.Silent || (cfg.DryRun && !system.HasTTY()) {
685-
cfg.Macos = "configure"
686-
} else {
687-
configure, err := ui.Confirm("Apply developer-friendly macOS preferences?", true)
688-
if err != nil {
689-
return err
690-
}
691-
if !configure {
692-
ui.Muted("Skipping macOS preferences")
693-
fmt.Println()
694-
return nil
695-
}
696-
cfg.Macos = "configure"
697-
}
698-
}
699-
700-
if cfg.Macos == "configure" {
683+
// --macos configure flag or non-interactive mode: apply all defaults directly.
684+
if cfg.Macos == "configure" || cfg.Silent || (cfg.DryRun && !system.HasTTY()) {
701685
if err := macos.CreateScreenshotsDir(cfg.DryRun); err != nil {
702686
ui.Error(fmt.Sprintf("Failed to create Screenshots dir: %v", err))
703687
}
704-
705688
if err := macos.Configure(macos.DefaultPreferences, cfg.DryRun); err != nil {
706689
ui.Warn(fmt.Sprintf("Some macOS preferences could not be set: %v", err))
707690
}
708-
709691
if !cfg.DryRun {
710692
ui.Success("macOS preferences configured")
711693
macos.RestartAffectedApps(cfg.DryRun)
712694
}
695+
fmt.Println()
696+
return nil
697+
}
698+
699+
ui.Info("Choose which macOS preferences to apply")
700+
ui.Muted("Use Tab to switch categories, Space to toggle, Enter to confirm")
701+
fmt.Println()
702+
703+
selected, confirmed, err := ui.RunMacOSSelector()
704+
if err != nil {
705+
return fmt.Errorf("macOS selector: %w", err)
706+
}
707+
708+
if !confirmed || len(selected) == 0 {
709+
ui.Muted("Skipping macOS preferences")
710+
fmt.Println()
711+
return nil
712+
}
713+
714+
if err := macos.CreateScreenshotsDir(cfg.DryRun); err != nil {
715+
ui.Error(fmt.Sprintf("Failed to create Screenshots dir: %v", err))
716+
}
717+
718+
if err := macos.Configure(selected, cfg.DryRun); err != nil {
719+
ui.Warn(fmt.Sprintf("Some macOS preferences could not be set: %v", err))
720+
}
721+
722+
if !cfg.DryRun {
723+
ui.Success(fmt.Sprintf("macOS preferences configured (%d settings)", len(selected)))
724+
macos.RestartAffectedApps(cfg.DryRun)
713725
}
714726

715727
fmt.Println()

internal/installer/installer_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,26 @@ func TestStepMacOS_Skip(t *testing.T) {
353353
assert.NoError(t, err)
354354
}
355355

356+
func TestStepMacOS_ConfigureFlag_DryRun(t *testing.T) {
357+
// --macos configure bypasses the TUI and applies all defaults directly.
358+
cfg := &config.Config{
359+
Macos: "configure",
360+
DryRun: true,
361+
}
362+
err := stepMacOS(cfg)
363+
assert.NoError(t, err)
364+
}
365+
366+
func TestStepMacOS_Silent_DryRun(t *testing.T) {
367+
// Silent mode bypasses the TUI and applies all defaults directly.
368+
cfg := &config.Config{
369+
Silent: true,
370+
DryRun: true,
371+
}
372+
err := stepMacOS(cfg)
373+
assert.NoError(t, err)
374+
}
375+
356376
func TestInstallTimeConstants(t *testing.T) {
357377
assert.Equal(t, 15, estimatedSecondsPerFormula)
358378
assert.Equal(t, 30, estimatedSecondsPerCask)

internal/macos/categories.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package macos
2+
3+
// PrefCategory groups related macOS preferences for display in the TUI selector.
4+
type PrefCategory struct {
5+
Name string
6+
Icon string
7+
Prefs []Preference
8+
}
9+
10+
// PrefKey returns a unique identifier for a preference, used as the selection map key.
11+
func PrefKey(p Preference) string {
12+
return p.Domain + "/" + p.Key
13+
}
14+
15+
// DefaultCategories groups DefaultPreferences by logical category.
16+
var DefaultCategories = []PrefCategory{
17+
{
18+
Name: "System",
19+
Icon: "⚙",
20+
Prefs: []Preference{
21+
{"NSGlobalDomain", "AppleShowAllExtensions", "bool", "true", "Show all file extensions"},
22+
{"NSGlobalDomain", "AppleShowScrollBars", "string", "Always", "Always show scrollbars"},
23+
{"NSGlobalDomain", "NSAutomaticSpellingCorrectionEnabled", "bool", "false", "Disable auto-correct"},
24+
{"NSGlobalDomain", "NSAutomaticCapitalizationEnabled", "bool", "false", "Disable auto-capitalization"},
25+
{"NSGlobalDomain", "KeyRepeat", "int", "2", "Fast key repeat rate"},
26+
{"NSGlobalDomain", "InitialKeyRepeat", "int", "15", "Short delay until key repeat"},
27+
},
28+
},
29+
{
30+
Name: "Finder",
31+
Icon: "📁",
32+
Prefs: []Preference{
33+
{"com.apple.finder", "ShowPathbar", "bool", "true", "Show path bar in Finder"},
34+
{"com.apple.finder", "ShowStatusBar", "bool", "true", "Show status bar in Finder"},
35+
{"com.apple.finder", "FXPreferredViewStyle", "string", "Nlsv", "Use list view in Finder"},
36+
{"com.apple.finder", "FXEnableExtensionChangeWarning", "bool", "false", "No extension change warning"},
37+
{"com.apple.finder", "AppleShowAllFiles", "bool", "true", "Show hidden files in Finder"},
38+
},
39+
},
40+
{
41+
Name: "Dock",
42+
Icon: "🚢",
43+
Prefs: []Preference{
44+
{"com.apple.dock", "autohide", "bool", "false", "Keep Dock visible"},
45+
{"com.apple.dock", "show-recents", "bool", "false", "Don't show recent apps in Dock"},
46+
{"com.apple.dock", "tilesize", "int", "48", "Set Dock icon size to 48"},
47+
{"com.apple.dock", "mineffect", "string", "scale", "Minimize windows with scale effect"},
48+
},
49+
},
50+
{
51+
Name: "Screenshots",
52+
Icon: "📸",
53+
Prefs: []Preference{
54+
{"com.apple.screencapture", "location", "string", "~/Screenshots", "Save screenshots to ~/Screenshots"},
55+
{"com.apple.screencapture", "type", "string", "png", "Save screenshots as PNG"},
56+
{"com.apple.screencapture", "disable-shadow", "bool", "true", "Disable screenshot shadows"},
57+
},
58+
},
59+
{
60+
Name: "Safari",
61+
Icon: "🌐",
62+
Prefs: []Preference{
63+
{"com.apple.Safari", "IncludeDevelopMenu", "bool", "true", "Enable Safari Developer menu"},
64+
{"com.apple.Safari", "WebKitDeveloperExtrasEnabledPreferenceKey", "bool", "true", "Enable Safari WebKit dev extras"},
65+
},
66+
},
67+
{
68+
Name: "TextEdit",
69+
Icon: "📝",
70+
Prefs: []Preference{
71+
{"com.apple.TextEdit", "RichText", "bool", "false", "Use plain text in TextEdit"},
72+
{"com.apple.TextEdit", "PlainTextEncoding", "int", "4", "Use UTF-8 in TextEdit"},
73+
},
74+
},
75+
{
76+
Name: "TimeMachine",
77+
Icon: "💾",
78+
Prefs: []Preference{
79+
{"com.apple.TimeMachine", "DoNotOfferNewDisksForBackup", "bool", "true", "Don't prompt for Time Machine on new disks"},
80+
},
81+
},
82+
}
83+
84+
// AllPrefsSelected returns a map with all default preferences set to true.
85+
func AllPrefsSelected() map[string]bool {
86+
selected := make(map[string]bool)
87+
for _, cat := range DefaultCategories {
88+
for _, p := range cat.Prefs {
89+
selected[PrefKey(p)] = true
90+
}
91+
}
92+
return selected
93+
}

internal/macos/categories_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package macos
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestDefaultCategories_NotEmpty(t *testing.T) {
11+
assert.Greater(t, len(DefaultCategories), 0)
12+
}
13+
14+
func TestDefaultCategories_HasRequiredFields(t *testing.T) {
15+
for _, cat := range DefaultCategories {
16+
assert.NotEmpty(t, cat.Name, "category Name should not be empty")
17+
assert.NotEmpty(t, cat.Icon, "category Icon should not be empty")
18+
assert.Greater(t, len(cat.Prefs), 0, "category %q should have at least one preference", cat.Name)
19+
}
20+
}
21+
22+
func TestDefaultCategories_ExpectedNames(t *testing.T) {
23+
names := make(map[string]bool)
24+
for _, cat := range DefaultCategories {
25+
names[cat.Name] = true
26+
}
27+
for _, expected := range []string{"System", "Finder", "Dock", "Screenshots", "Safari", "TextEdit", "TimeMachine"} {
28+
assert.True(t, names[expected], "expected category %q to exist", expected)
29+
}
30+
}
31+
32+
func TestDefaultPreferences_DerivedFromCategories(t *testing.T) {
33+
// DefaultPreferences must be exactly the flat concatenation of all category prefs.
34+
var expected []Preference
35+
for _, cat := range DefaultCategories {
36+
expected = append(expected, cat.Prefs...)
37+
}
38+
assert.Equal(t, expected, DefaultPreferences,
39+
"DefaultPreferences must equal the flat concat of DefaultCategories prefs")
40+
}
41+
42+
func TestDefaultPreferences_NoDuplicateKeys(t *testing.T) {
43+
seen := make(map[string]bool)
44+
for _, p := range DefaultPreferences {
45+
k := PrefKey(p)
46+
assert.False(t, seen[k], "duplicate PrefKey %q", k)
47+
seen[k] = true
48+
}
49+
}
50+
51+
func TestPrefKey_Format(t *testing.T) {
52+
p := Preference{Domain: "com.apple.finder", Key: "ShowPathbar"}
53+
assert.Equal(t, "com.apple.finder/ShowPathbar", PrefKey(p))
54+
}
55+
56+
func TestPrefKey_UniqueAcrossCategories(t *testing.T) {
57+
keys := make(map[string]bool)
58+
for _, cat := range DefaultCategories {
59+
for _, p := range cat.Prefs {
60+
k := PrefKey(p)
61+
assert.False(t, keys[k], "duplicate PrefKey %q in categories", k)
62+
keys[k] = true
63+
}
64+
}
65+
}
66+
67+
func TestAllPrefsSelected_CountMatchesDefaultPreferences(t *testing.T) {
68+
selected := AllPrefsSelected()
69+
assert.Equal(t, len(DefaultPreferences), len(selected))
70+
}
71+
72+
func TestAllPrefsSelected_AllTrue(t *testing.T) {
73+
selected := AllPrefsSelected()
74+
for k, v := range selected {
75+
assert.True(t, v, "expected preference %q to be selected", k)
76+
}
77+
}
78+
79+
func TestAllPrefsSelected_KeysMatchDefaultCategories(t *testing.T) {
80+
selected := AllPrefsSelected()
81+
for _, cat := range DefaultCategories {
82+
for _, p := range cat.Prefs {
83+
k := PrefKey(p)
84+
_, ok := selected[k]
85+
assert.True(t, ok, "expected key %q to be present in AllPrefsSelected", k)
86+
}
87+
}
88+
}
89+
90+
func TestDefaultCategories_PrefsHaveRequiredFields(t *testing.T) {
91+
validTypes := map[string]bool{"bool": true, "int": true, "float": true, "string": true}
92+
for _, cat := range DefaultCategories {
93+
for _, p := range cat.Prefs {
94+
assert.NotEmpty(t, p.Domain, "pref in category %q has empty Domain", cat.Name)
95+
assert.NotEmpty(t, p.Key, "pref in category %q has empty Key", cat.Name)
96+
assert.NotEmpty(t, p.Desc, "pref in category %q has empty Desc", cat.Name)
97+
assert.True(t, validTypes[p.Type], "pref %q in category %q has invalid Type %q", p.Key, cat.Name, p.Type)
98+
}
99+
}
100+
}
101+
102+
func TestDefaultCategories_FinderCategory(t *testing.T) {
103+
var finder *PrefCategory
104+
for i := range DefaultCategories {
105+
if DefaultCategories[i].Name == "Finder" {
106+
finder = &DefaultCategories[i]
107+
break
108+
}
109+
}
110+
require.NotNil(t, finder, "Finder category must exist")
111+
assert.Equal(t, "📁", finder.Icon)
112+
assert.Greater(t, len(finder.Prefs), 0)
113+
}
114+
115+
func TestDefaultCategories_DockCategory(t *testing.T) {
116+
var dock *PrefCategory
117+
for i := range DefaultCategories {
118+
if DefaultCategories[i].Name == "Dock" {
119+
dock = &DefaultCategories[i]
120+
break
121+
}
122+
}
123+
require.NotNil(t, dock, "Dock category must exist")
124+
assert.Greater(t, len(dock.Prefs), 0)
125+
}

internal/macos/macos.go

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,15 @@ type Preference struct {
1919
Desc string
2020
}
2121

22-
var DefaultPreferences = []Preference{
23-
{"NSGlobalDomain", "AppleShowAllExtensions", "bool", "true", "Show all file extensions"},
24-
{"NSGlobalDomain", "AppleShowScrollBars", "string", "Always", "Always show scrollbars"},
25-
{"NSGlobalDomain", "NSAutomaticSpellingCorrectionEnabled", "bool", "false", "Disable auto-correct"},
26-
{"NSGlobalDomain", "NSAutomaticCapitalizationEnabled", "bool", "false", "Disable auto-capitalization"},
27-
{"NSGlobalDomain", "KeyRepeat", "int", "2", "Fast key repeat rate"},
28-
{"NSGlobalDomain", "InitialKeyRepeat", "int", "15", "Short delay until key repeat"},
29-
30-
{"com.apple.finder", "ShowPathbar", "bool", "true", "Show path bar in Finder"},
31-
{"com.apple.finder", "ShowStatusBar", "bool", "true", "Show status bar in Finder"},
32-
{"com.apple.finder", "FXPreferredViewStyle", "string", "Nlsv", "Use list view in Finder"},
33-
{"com.apple.finder", "FXEnableExtensionChangeWarning", "bool", "false", "No extension change warning"},
34-
{"com.apple.finder", "AppleShowAllFiles", "bool", "true", "Show hidden files in Finder"},
35-
36-
{"com.apple.dock", "autohide", "bool", "false", "Keep Dock visible"},
37-
{"com.apple.dock", "show-recents", "bool", "false", "Don't show recent apps in Dock"},
38-
{"com.apple.dock", "tilesize", "int", "48", "Set Dock icon size"},
39-
{"com.apple.dock", "mineffect", "string", "scale", "Minimize windows with scale effect"},
40-
41-
{"com.apple.screencapture", "location", "string", "~/Screenshots", "Save screenshots to ~/Screenshots"},
42-
{"com.apple.screencapture", "type", "string", "png", "Save screenshots as PNG"},
43-
{"com.apple.screencapture", "disable-shadow", "bool", "true", "Disable screenshot shadows"},
44-
45-
{"com.apple.Safari", "IncludeDevelopMenu", "bool", "true", "Enable Safari Developer menu"},
46-
{"com.apple.Safari", "WebKitDeveloperExtrasEnabledPreferenceKey", "bool", "true", "Enable Safari WebKit dev extras"},
47-
48-
{"com.apple.TextEdit", "RichText", "bool", "false", "Use plain text in TextEdit"},
49-
{"com.apple.TextEdit", "PlainTextEncoding", "int", "4", "Use UTF-8 in TextEdit"},
50-
51-
{"com.apple.TimeMachine", "DoNotOfferNewDisksForBackup", "bool", "true", "Don't prompt for Time Machine on new disks"},
52-
}
22+
// DefaultPreferences is derived from DefaultCategories and is the single source
23+
// of truth for all macOS preferences. Add or change preferences in categories.go.
24+
var DefaultPreferences = func() []Preference {
25+
var prefs []Preference
26+
for _, cat := range DefaultCategories {
27+
prefs = append(prefs, cat.Prefs...)
28+
}
29+
return prefs
30+
}()
5331

5432
func normalizeBool(value string) string {
5533
switch strings.ToLower(value) {

0 commit comments

Comments
 (0)