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