1package usecase
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "strings"
8
9 "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/validation"
12)
13
14type SaveWebUIConfigUseCase struct {
15 saver port.WebUIConfigSaver
16}
17
18func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase {
19 return &SaveWebUIConfigUseCase{saver: saver}
20}
21
22func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
23 if uc == nil || uc.saver == nil {
24 return fmt.Errorf("config saver is nil")
25 }
26
27 if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28 return err
29 }
30
31 normalized := normalizeWebUIConfig(cfg)
32 if err := validateWebUIConfig(normalized); err != nil {
33 return err
34 }
35
36 return uc.saver.SaveWebUIConfig(ctx, normalized)
37}
38
39func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error {
40 seen := make(map[string]string, len(shortcuts))
41 for key := range shortcuts {
42 trimmedKey := strings.TrimSpace(key)
43 if trimmedKey == "" {
44 continue
45 }
46 if previous, ok := seen[trimmedKey]; ok && previous != key {
47 return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous)
48 }
49 seen[trimmedKey] = key
50 }
51 return nil
52}
53
54func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
55 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
56 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
57 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
58 cfg.Appearance.GtkFont = strings.TrimSpace(cfg.Appearance.GtkFont)
59 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
60 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
61 cfg.Performance.Profile = strings.TrimSpace(cfg.Performance.Profile)
62 if cfg.Performance.Profile == performanceProfileCustom {
63 cfg.Performance.Custom = clampCustomPerformance(cfg.Performance.Custom)
64 }
65 if len(cfg.SearchShortcuts) > 0 {
66 normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
67 for key, shortcut := range cfg.SearchShortcuts {
68 trimmedKey := strings.TrimSpace(key)
69 if trimmedKey == "" {
70 continue
71 }
72 shortcut.URL = strings.TrimSpace(shortcut.URL)
73 shortcut.Description = strings.TrimSpace(shortcut.Description)
74 normalized[trimmedKey] = shortcut
75 }
76 cfg.SearchShortcuts = normalized
77 }
78 return cfg
79}
80
81const performanceProfileCustom = "custom"
82
83// Bounds for user-editable custom performance fields.
84const (
85 maxSkiaCPUThreads = 8
86 maxSkiaGPUThreads = 8
87 maxWebProcessMemoryMB = 16384
88 maxNetworkProcessMemoryMB = 4096
89 maxWebViewPoolPrewarm = 20
90)
91
92// clampCustomPerformance keeps custom profile values inside supported bounds.
93// A GPU thread count of -1 means "engine default".
94func clampCustomPerformance(c dto.WebUICustomPerformanceConfig) dto.WebUICustomPerformanceConfig {
95 c.SkiaCPUThreads = min(max(c.SkiaCPUThreads, 0), maxSkiaCPUThreads)
96 c.SkiaGPUThreads = min(max(c.SkiaGPUThreads, -1), maxSkiaGPUThreads)
97 c.WebProcessMemoryMB = min(max(c.WebProcessMemoryMB, 0), maxWebProcessMemoryMB)
98 c.NetworkProcessMemoryMB = min(max(c.NetworkProcessMemoryMB, 0), maxNetworkProcessMemoryMB)
99 c.WebViewPoolPrewarm = min(max(c.WebViewPoolPrewarm, 0), maxWebViewPoolPrewarm)
100 return c
101}
102
103func validateWebUIConfig(cfg dto.WebUIConfig) error {
104 var errs []string
105
106 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
107 errs = append(errs, "appearance.default_font_size must be between 1 and 72")
108 }
109 if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
110 errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
111 }
112
113 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
114 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
115 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
116 errs = append(errs, validation.ValidateFontFamily("appearance.gtk_font", cfg.Appearance.GtkFont)...)
117
118 for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
119 errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
120 }
121
122 for key, shortcut := range cfg.SearchShortcuts {
123 for _, err := range validation.ValidateShortcutKey(key) {
124 errs = append(errs, fmt.Sprintf("search_shortcuts.%s: %s", key, err))
125 }
126 for _, err := range validation.ValidateShortcutURL(shortcut.URL) {
127 errs = append(errs, fmt.Sprintf("search_shortcuts.%s.url: %s", key, err))
128 }
129 for _, err := range validation.ValidateShortcutDescription(shortcut.Description) {
130 errs = append(errs, fmt.Sprintf("search_shortcuts.%s.description: %s", key, err))
131 }
132 }
133
134 switch cfg.Performance.Profile {
135 case "", "default", "lite", "balanced", "max", "custom":
136 // ok
137 default:
138 errs = append(errs, fmt.Sprintf(
139 "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
140 cfg.Performance.Profile,
141 ))
142 }
143
144 switch cfg.Appearance.ColorScheme {
145 case "default", "prefer-dark", "prefer-light":
146 // ok
147 default:
148 errs = append(errs, fmt.Sprintf(
149 "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
150 cfg.Appearance.ColorScheme,
151 ))
152 }
153
154 errs = append(errs, validation.ValidatePaletteHex(
155 "appearance.light_palette",
156 cfg.Appearance.LightPalette.Background,
157 cfg.Appearance.LightPalette.Surface,
158 cfg.Appearance.LightPalette.SurfaceVariant,
159 cfg.Appearance.LightPalette.Text,
160 cfg.Appearance.LightPalette.Muted,
161 cfg.Appearance.LightPalette.Accent,
162 cfg.Appearance.LightPalette.Border,
163 )...)
164 errs = append(errs, validation.ValidatePaletteHex(
165 "appearance.dark_palette",
166 cfg.Appearance.DarkPalette.Background,
167 cfg.Appearance.DarkPalette.Surface,
168 cfg.Appearance.DarkPalette.SurfaceVariant,
169 cfg.Appearance.DarkPalette.Text,
170 cfg.Appearance.DarkPalette.Muted,
171 cfg.Appearance.DarkPalette.Accent,
172 cfg.Appearance.DarkPalette.Border,
173 )...)
174
175 if len(errs) > 0 {
176 return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - "))
177 }
178 return nil
179}