1package systemviews
2
3import (
4 "context"
5 "fmt"
6 "maps"
7 "math"
8 "net/url"
9 "strconv"
10 "strings"
11 "sync/atomic"
12
13 "github.com/bnema/dumber/internal/application/dto"
14 "github.com/bnema/dumber/internal/application/port"
15)
16
17const (
18 configActionSaveAppearance = "config.appearance.save"
19 configActionResetAppearance = "config.appearance.reset"
20 configActionSaveSearch = "config.search.save"
21 configActionCreateSearchShortcut = "config.searchShortcut.create"
22 configActionUpdateSearchShortcut = "config.searchShortcut.update"
23 configActionDeleteSearchShortcut = "config.searchShortcut.delete"
24 configActionSavePerformance = "config.performance.save"
25 configActionSetKeybinding = "config.keybinding.set"
26 configActionResetKeybinding = "config.keybinding.reset"
27 configActionResetAllKeybindings = "config.keybinding.resetAll"
28 configRequestIDPrefix = "systemviews-config"
29 balancedPerformanceProfile = "balanced"
30 customPerformanceProfile = "custom"
31 httpScheme = "http"
32 maxCustomPerformanceThreads = 8
33 maxCustomPerformanceWebMemoryMB = 16384
34 maxCustomPerformanceNetworkMemoryMB = 4096
35 maxCustomPerformancePrewarm = 20
36)
37
38var configRequestCounter atomic.Uint64
39
40func nextConfigRequestID() string {
41 return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1))
42}
43
44func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
45 if a.deps.Config == nil {
46 return fmt.Errorf("config service not configured")
47 }
48
49 switch event.Action {
50 case configActionSaveAppearance:
51 return a.saveAppearanceConfig(ctx, event.Data)
52 case configActionResetAppearance:
53 return a.resetAppearanceConfig(ctx)
54 case configActionSaveSearch:
55 return a.saveSearchConfig(ctx, event.Data)
56 case configActionCreateSearchShortcut:
57 return a.createSearchShortcut(ctx, event.Data)
58 case configActionUpdateSearchShortcut:
59 return a.updateSearchShortcut(ctx, event.Data)
60 case configActionDeleteSearchShortcut:
61 return a.deleteSearchShortcut(ctx, event.Data)
62 case configActionSavePerformance:
63 return a.savePerformanceConfig(ctx, event.Data)
64 case configActionSetKeybinding:
65 return a.setConfigKeybinding(ctx, event.Data)
66 case configActionResetKeybinding:
67 return a.resetConfigKeybinding(ctx, event.Data)
68 case configActionResetAllKeybindings:
69 if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil {
70 return err
71 }
72 a.configNotice = "Reset all keybindings to defaults"
73 return nil
74 default:
75 return fmt.Errorf("unknown config action: %q", event.Action)
76 }
77}
78
79func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
80 cfg, err := a.editableConfig(ctx)
81 if err != nil {
82 return err
83 }
84
85 fontSize, err := parseConfigInt(data["default_font_size"], "default font size")
86 if err != nil {
87 return err
88 }
89 uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale")
90 if err != nil {
91 return err
92 }
93 if fontSize <= 0 {
94 return fmt.Errorf("default font size must be positive")
95 }
96 if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) {
97 return fmt.Errorf("UI scale must be a finite positive number")
98 }
99
100 cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
101 cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
102 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
103 cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"])
104 cfg.Appearance.DefaultFontSize = fontSize
105 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
106 cfg.Appearance.ExternalTheme.Enabled = configCheckboxEnabled(data["external_theme_enabled"])
107 cfg.Appearance.ExternalTheme.Provider = strings.TrimSpace(data["external_theme_provider"])
108 cfg.Appearance.ExternalTheme.Format = strings.TrimSpace(data["external_theme_format"])
109 cfg.Appearance.ExternalTheme.Path = strings.TrimSpace(data["external_theme_path"])
110 cfg.Appearance.LightPalette = paletteFromForm(data, "light")
111 cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
112 cfg.DefaultUIScale = uiScale
113
114 if err := a.saveEditableConfig(ctx, cfg); err != nil {
115 return err
116 }
117 a.configNotice = "Saved appearance settings"
118 return nil
119}
120
121func configCheckboxEnabled(value string) bool {
122 switch strings.ToLower(strings.TrimSpace(value)) {
123 case "on", "true", "1", "yes":
124 return true
125 default:
126 return false
127 }
128}
129
130func (a *App) resetAppearanceConfig(ctx context.Context) error {
131 cfg, err := a.editableConfig(ctx)
132 if err != nil {
133 return err
134 }
135 defaults, err := a.deps.Config.Default(ctx)
136 if err != nil {
137 return err
138 }
139 cfg.Appearance = defaults.Appearance
140 cfg.DefaultUIScale = defaults.DefaultUIScale
141 if err := a.saveEditableConfig(ctx, cfg); err != nil {
142 return err
143 }
144 a.configNotice = "Reset appearance settings to defaults"
145 return nil
146}
147
148func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error {
149 cfg, err := a.editableConfig(ctx)
150 if err != nil {
151 return err
152 }
153 defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine")
154 if err != nil {
155 return err
156 }
157 cfg.DefaultSearchEngine = defaultSearchEngine
158 if err := a.saveEditableConfig(ctx, cfg); err != nil {
159 return err
160 }
161 a.configNotice = "Saved default search engine"
162 return nil
163}
164
165func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error {
166 cfg, err := a.editableConfig(ctx)
167 if err != nil {
168 return err
169 }
170 key, shortcut := searchShortcutFromForm(data, "key")
171 if key == "" {
172 return fmt.Errorf("search shortcut key is required")
173 }
174 if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
175 return err
176 }
177 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
178 if _, exists := cfg.SearchShortcuts[key]; exists {
179 return fmt.Errorf("search shortcut %q already exists", key)
180 }
181 cfg.SearchShortcuts[key] = shortcut
182 if err := a.saveEditableConfig(ctx, cfg); err != nil {
183 return err
184 }
185 a.configNotice = "Created search shortcut " + key
186 return nil
187}
188
189func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error {
190 cfg, err := a.editableConfig(ctx)
191 if err != nil {
192 return err
193 }
194 oldKey := strings.TrimSpace(data["key"])
195 if oldKey == "" {
196 return fmt.Errorf("search shortcut key is required")
197 }
198 newKey, shortcut := searchShortcutFromForm(data, "new_key")
199 if newKey == "" {
200 return fmt.Errorf("new search shortcut key is required")
201 }
202 if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
203 return err
204 }
205 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
206 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
207 return fmt.Errorf("search shortcut %q not found", oldKey)
208 }
209 if newKey != oldKey {
210 if _, exists := cfg.SearchShortcuts[newKey]; exists {
211 return fmt.Errorf("search shortcut %q already exists", newKey)
212 }
213 delete(cfg.SearchShortcuts, oldKey)
214 }
215 cfg.SearchShortcuts[newKey] = shortcut
216 if err := a.saveEditableConfig(ctx, cfg); err != nil {
217 return err
218 }
219 a.configNotice = "Saved search shortcut " + newKey
220 return nil
221}
222
223func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error {
224 cfg, err := a.editableConfig(ctx)
225 if err != nil {
226 return err
227 }
228 key := strings.TrimSpace(data["key"])
229 if key == "" {
230 return fmt.Errorf("search shortcut key is required")
231 }
232 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
233 if _, exists := cfg.SearchShortcuts[key]; !exists {
234 return fmt.Errorf("search shortcut %q not found", key)
235 }
236 delete(cfg.SearchShortcuts, key)
237 if err := a.saveEditableConfig(ctx, cfg); err != nil {
238 return err
239 }
240 a.configNotice = "Deleted search shortcut " + key
241 return nil
242}
243
244func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error {
245 cfg, err := a.editableConfig(ctx)
246 if err != nil {
247 return err
248 }
249 profile := strings.TrimSpace(data["profile"])
250 if !isAllowedPerformanceProfile(profile) {
251 return fmt.Errorf("unknown performance profile: %q", profile)
252 }
253 cfg.Performance.Profile = profile
254 if profile != customPerformanceProfile {
255 if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil {
256 return saveErr
257 }
258 a.configNotice = "Saved performance settings. Restart may be required."
259 return nil
260 }
261
262 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
263 if err != nil {
264 return err
265 }
266 skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads")
267 if err != nil {
268 return err
269 }
270 webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory")
271 if err != nil {
272 return err
273 }
274 networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory")
275 if err != nil {
276 return err
277 }
278 prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm")
279 if err != nil {
280 return err
281 }
282 if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil {
283 return err
284 }
285 if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil {
286 return err
287 }
288 if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil {
289 return err
290 }
291 if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil {
292 return err
293 }
294 if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil {
295 return err
296 }
297
298 cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{
299 SkiaCPUThreads: skiaCPU,
300 SkiaGPUThreads: skiaGPU,
301 WebProcessMemoryMB: webMemory,
302 NetworkProcessMemoryMB: networkMemory,
303 WebViewPoolPrewarm: prewarm,
304 }
305 if err := a.saveEditableConfig(ctx, cfg); err != nil {
306 return err
307 }
308 a.configNotice = "Saved performance settings. Restart may be required."
309 return nil
310}
311
312func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error {
313 mode := strings.TrimSpace(data["mode"])
314 action := strings.TrimSpace(data["action"])
315 keys := parseKeyList(data["keys"])
316 if mode == "" || action == "" {
317 return fmt.Errorf("mode and action are required")
318 }
319 if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
320 return err
321 }
322 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
323 RequestID: nextConfigRequestID(),
324 Mode: mode,
325 Action: action,
326 Keys: keys,
327 })
328 if err != nil {
329 return err
330 }
331 if conflicts := len(resp.Conflicts); conflicts > 0 {
332 a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
333 return nil
334 }
335 a.configNotice = "Saved keybinding " + action
336 return nil
337}
338
339func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error {
340 mode := strings.TrimSpace(data["mode"])
341 action := strings.TrimSpace(data["action"])
342 if mode == "" || action == "" {
343 return fmt.Errorf("mode and action are required")
344 }
345 if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
346 return err
347 }
348 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
349 RequestID: nextConfigRequestID(),
350 Mode: mode,
351 Action: action,
352 }); err != nil {
353 return err
354 }
355 a.configNotice = "Reset keybinding " + action
356 return nil
357}
358
359func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) {
360 cfg, err := a.deps.Config.Current(ctx)
361 if err != nil {
362 return dto.WebUIConfig{}, err
363 }
364 a.config = &cfg
365 return webUIConfigFromPayload(cfg), nil
366}
367
368func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error {
369 keybindings, err := a.deps.Config.GetKeybindings(ctx)
370 if err != nil {
371 return err
372 }
373 a.keybindings = keybindings
374 groups := configKeybindingGroups(keybindings)
375 for _, group := range groups {
376 if group.Mode != mode {
377 continue
378 }
379 for _, binding := range group.Bindings {
380 if binding.Action == action {
381 return nil
382 }
383 }
384 }
385 return fmt.Errorf("keybinding %s/%s not found", mode, action)
386}
387
388func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error {
389 if err := a.deps.Config.Save(ctx, cfg); err != nil {
390 return err
391 }
392 a.config = nil
393 return nil
394}
395
396func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig {
397 return dto.WebUIConfig{
398 Appearance: payload.Appearance,
399 DefaultUIScale: payload.DefaultUIScale,
400 DefaultSearchEngine: payload.DefaultSearchEngine,
401 SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
402 Performance: dto.WebUIPerformanceConfig{
403 Profile: payload.Performance.Profile,
404 Custom: dto.WebUICustomPerformanceConfig{
405 SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
406 SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
407 WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
408 NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB,
409 WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm,
410 },
411 },
412 }
413}
414
415func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut {
416 clone := make(map[string]dto.SearchShortcut, len(shortcuts))
417 maps.Copy(clone, shortcuts)
418 return clone
419}
420
421func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette {
422 color := func(field string) string {
423 return strings.TrimSpace(data[prefix+"_"+field])
424 }
425
426 return dto.ColorPalette{
427 Background: color("background"),
428 Surface: color("surface"),
429 SurfaceVariant: color("surface_variant"),
430 Text: color("text"),
431 Muted: color("muted"),
432 Accent: color("accent"),
433 Border: color("border"),
434 }
435}
436
437func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
438 return strings.TrimSpace(data[keyField]), dto.SearchShortcut{
439 URL: strings.TrimSpace(data["url"]),
440 Description: strings.TrimSpace(data["description"]),
441 }
442}
443
444func requireSearchURLTemplate(raw, label string) (string, error) {
445 value := strings.TrimSpace(raw)
446 if value == "" {
447 return "", fmt.Errorf("%s is required", label)
448 }
449 if !strings.Contains(value, "%s") {
450 return "", fmt.Errorf("%s must include %%s placeholder", label)
451 }
452 parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
453 if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") {
454 return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
455 }
456 return value, nil
457}
458
459func parseConfigInt(raw, label string) (int, error) {
460 value, err := strconv.Atoi(strings.TrimSpace(raw))
461 if err != nil {
462 return 0, fmt.Errorf("invalid %s", label)
463 }
464 return value, nil
465}
466
467func parseConfigFloat(raw, label string) (float64, error) {
468 value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
469 if err != nil {
470 return 0, fmt.Errorf("invalid %s", label)
471 }
472 return value, nil
473}
474
475func isAllowedPerformanceProfile(profile string) bool {
476 switch profile {
477 case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile:
478 return true
479 default:
480 return false
481 }
482}
483
484func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
485 if value < minValue || value > maxValue {
486 return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
487 }
488 return nil
489}
490
491func parseKeyList(raw string) []string {
492 raw = strings.TrimSpace(raw)
493 if raw == "" {
494 return nil
495 }
496 raw = strings.ReplaceAll(raw, ",", " ")
497 parts := strings.Fields(raw)
498 keys := make([]string, 0, len(parts))
499 for _, part := range parts {
500 key := strings.TrimSpace(part)
501 if key != "" {
502 keys = append(keys, key)
503 }
504 }
505 return keys
506}