1package config
2
3import (
4 "fmt"
5 "math"
6 "net/url"
7 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11
12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14)
15
16const cefLogSeverityDisabled = 99
17
18// validateConfig performs comprehensive validation of configuration values
19func validateConfig(config *Config) error {
20 var validationErrors []string
21
22 validationErrors = append(validationErrors, validateHistory(config)...)
23 validationErrors = append(validationErrors, validateDmenu(config)...)
24 validationErrors = append(validationErrors, validateAppearance(config)...)
25 validationErrors = append(validationErrors, validateSearchEngine(config)...)
26 validationErrors = append(validationErrors, validatePopups(config)...)
27 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
28 validationErrors = append(validationErrors, validatePaneMode(config)...)
29 validationErrors = append(validationErrors, validateTabBar(config)...)
30 validationErrors = append(validationErrors, validateTabMode(config)...)
31 validationErrors = append(validationErrors, validateFloatingPane(config)...)
32 validationErrors = append(validationErrors, validateLogging(config)...)
33 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
34 validationErrors = append(validationErrors, validateOmnibox(config)...)
35 validationErrors = append(validationErrors, validateEngine(config)...)
36 validationErrors = append(validationErrors, validateRendering(config)...)
37 validationErrors = append(validationErrors, validatePrivacy(config)...)
38 validationErrors = append(validationErrors, validateColorScheme(config)...)
39 validationErrors = append(validationErrors, validateSession(config)...)
40 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
41 validationErrors = append(validationErrors, validateCEF(config)...)
42
43 // If there are validation errors, return them
44 if len(validationErrors) > 0 {
45 return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
46 }
47
48 return nil
49}
50
51func validateHistory(config *Config) []string {
52 var validationErrors []string
53 if config.History.MaxEntries < 0 {
54 validationErrors = append(validationErrors, "history.max_entries must be non-negative")
55 }
56 if config.History.RetentionPeriodDays < 0 {
57 validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
58 }
59 if config.History.CleanupIntervalDays < 0 {
60 validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
61 }
62 return validationErrors
63}
64
65func validateDmenu(config *Config) []string {
66 if config.Dmenu.MaxHistoryDays < 0 {
67 return []string{"dmenu.max_history_days must be non-negative"}
68 }
69 return nil
70}
71
72func validateAppearance(config *Config) []string {
73 var validationErrors []string
74 if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
75 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
76 }
77 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
78 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
79 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.gtk_font", config.Appearance.GtkFont)...)
80 validationErrors = append(
81 validationErrors,
82 domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
83 )
84 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
85 "appearance.light_palette",
86 config.Appearance.LightPalette.Background,
87 config.Appearance.LightPalette.Surface,
88 config.Appearance.LightPalette.SurfaceVariant,
89 config.Appearance.LightPalette.Text,
90 config.Appearance.LightPalette.Muted,
91 config.Appearance.LightPalette.Accent,
92 config.Appearance.LightPalette.Border,
93 )...)
94 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
95 "appearance.dark_palette",
96 config.Appearance.DarkPalette.Background,
97 config.Appearance.DarkPalette.Surface,
98 config.Appearance.DarkPalette.SurfaceVariant,
99 config.Appearance.DarkPalette.Text,
100 config.Appearance.DarkPalette.Muted,
101 config.Appearance.DarkPalette.Accent,
102 config.Appearance.DarkPalette.Border,
103 )...)
104 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
105 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
106 }
107 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
108 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
109 }
110 if config.SidebarWidth != 0 && (config.SidebarWidth < 280 || config.SidebarWidth > 380) {
111 validationErrors = append(validationErrors, "sidebar_width must be between 280 and 380, or 0 for default")
112 }
113 validationErrors = append(validationErrors, validateExternalTheme(config)...)
114 return validationErrors
115}
116
117func validateExternalTheme(config *Config) []string {
118 externalTheme := config.Appearance.ExternalTheme
119 if !externalTheme.Enabled {
120 return nil
121 }
122
123 var validationErrors []string
124 if externalTheme.Provider != defaultExternalThemeProvider {
125 validationErrors = append(validationErrors, "appearance.external_theme.provider must be one of: noctalia")
126 }
127 if !isSupportedExternalThemeFormat(externalTheme.Format) {
128 validationErrors = append(validationErrors, "appearance.external_theme.format must be one of: colors-json, dumber-json")
129 }
130 if strings.TrimSpace(externalTheme.Path) == "" {
131 validationErrors = append(validationErrors, "appearance.external_theme.path is required when external_theme is enabled")
132 }
133 return validationErrors
134}
135
136func isSupportedExternalThemeFormat(format string) bool {
137 switch format {
138 case defaultExternalThemeFormat, defaultExternalThemeTemplateFormat:
139 return true
140 default:
141 return false
142 }
143}
144
145func validateSearchEngine(config *Config) []string {
146 if config.DefaultSearchEngine == "" {
147 return []string{"default_search_engine cannot be empty"}
148 }
149 if !strings.Contains(config.DefaultSearchEngine, "%s") {
150 return []string{"default_search_engine must contain %s placeholder for the search query"}
151 }
152 return nil
153}
154
155func validatePopups(config *Config) []string {
156 var validationErrors []string
157 // Validate against BrowsingContexts (canonical field).
158 // Popups is a runtime alias synced during normalization.
159 switch config.Workspace.BrowsingContexts.Behavior {
160 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
161 default:
162 validationErrors = append(validationErrors, fmt.Sprintf(
163 "workspace.browsing_contexts.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
164 config.Workspace.BrowsingContexts.Behavior,
165 ))
166 }
167
168 if config.Workspace.BrowsingContexts.Behavior == PopupBehaviorSplit {
169 switch config.Workspace.BrowsingContexts.Placement {
170 case "right", "left", "top", "bottom":
171 default:
172 validationErrors = append(validationErrors, fmt.Sprintf(
173 "workspace.browsing_contexts.placement must be one of: right, left, top, bottom (got: %s)",
174 config.Workspace.BrowsingContexts.Placement,
175 ))
176 }
177 }
178
179 switch config.Workspace.BrowsingContexts.BlankTargetBehavior {
180 case "split", "stacked", "tabbed":
181 default:
182 validationErrors = append(validationErrors, fmt.Sprintf(
183 "workspace.browsing_contexts.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
184 config.Workspace.BrowsingContexts.BlankTargetBehavior,
185 ))
186 }
187 return validationErrors
188}
189
190func validateWorkspaceStyling(config *Config) []string {
191 var validationErrors []string
192 if config.Workspace.Styling.BorderWidth < 0 {
193 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
194 }
195 if config.Workspace.Styling.ModeBorderWidth < 0 {
196 validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
197 }
198 if config.Workspace.Styling.TransitionDuration < 0 {
199 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
200 }
201 return validationErrors
202}
203
204func validatePaneMode(config *Config) []string {
205 var validationErrors []string
206 if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
207 validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
208 }
209 if len(config.Workspace.PaneMode.Actions) == 0 {
210 validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
211 }
212
213 seenKeys := make(map[string]string)
214 for action, binding := range config.Workspace.PaneMode.Actions {
215 if len(binding.Keys) == 0 {
216 validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
217 }
218 for _, key := range binding.Keys {
219 if existingAction, exists := seenKeys[key]; exists {
220 validationErrors = append(validationErrors, fmt.Sprintf(
221 "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
222 key,
223 existingAction,
224 action,
225 ))
226 }
227 seenKeys[key] = action
228 }
229 }
230 return validationErrors
231}
232
233func validateTabBar(config *Config) []string {
234 switch config.Workspace.TabBarPosition {
235 case "top", "bottom":
236 return nil
237 default:
238 return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
239 }
240}
241
242func validateTabMode(config *Config) []string {
243 var validationErrors []string
244 if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
245 validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
246 }
247 if len(config.Workspace.TabMode.Actions) == 0 {
248 validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
249 }
250
251 tabSeenKeys := make(map[string]string)
252 for action, binding := range config.Workspace.TabMode.Actions {
253 if len(binding.Keys) == 0 {
254 validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
255 }
256 for _, key := range binding.Keys {
257 if existingAction, exists := tabSeenKeys[key]; exists {
258 validationErrors = append(validationErrors, fmt.Sprintf(
259 "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
260 key,
261 existingAction,
262 action,
263 ))
264 }
265 tabSeenKeys[key] = action
266 }
267 }
268 return validationErrors
269}
270
271func validateFloatingPane(config *Config) []string {
272 var validationErrors []string
273 seenProfileKeys := make(map[string]string)
274
275 if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
276 validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
277 }
278
279 if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
280 validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
281 }
282
283 profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles))
284 for profileName := range config.Workspace.FloatingPane.Profiles {
285 profileNames = append(profileNames, profileName)
286 }
287 sort.Strings(profileNames)
288
289 for _, profileName := range profileNames {
290 profile := config.Workspace.FloatingPane.Profiles[profileName]
291 if strings.TrimSpace(profile.URL) == "" {
292 validationErrors = append(validationErrors, fmt.Sprintf(
293 "workspace.floating_pane.profiles.%s.url is required",
294 profileName,
295 ))
296 } else {
297 validationErrors = append(validationErrors, validateWorkspaceURLValue(
298 fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
299 profile.URL,
300 )...)
301 }
302 if len(profile.Keys) == 0 {
303 validationErrors = append(validationErrors, fmt.Sprintf(
304 "workspace.floating_pane.profiles.%s.keys must have at least one entry",
305 profileName,
306 ))
307 }
308
309 for _, key := range profile.Keys {
310 normalizedKey := normalizeBindingKeyString(key)
311 if normalizedKey == "" {
312 validationErrors = append(validationErrors, fmt.Sprintf(
313 "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
314 profileName,
315 key,
316 ))
317 continue
318 }
319
320 if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
321 validationErrors = append(validationErrors, fmt.Sprintf(
322 "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
323 profileName,
324 key,
325 existingProfile,
326 ))
327 continue
328 }
329
330 seenProfileKeys[normalizedKey] = profileName
331 }
332 }
333
334 return validationErrors
335}
336
337func normalizeBindingKeyString(key string) string {
338 normalized := strings.ToLower(strings.TrimSpace(key))
339 if normalized == "" {
340 return ""
341 }
342 return strings.Join(strings.Fields(normalized), "")
343}
344
345func validateLogging(config *Config) []string {
346 var validationErrors []string
347 if config.Logging.MaxAge < 0 {
348 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
349 }
350 if config.Logging.MaxFiles < 0 {
351 validationErrors = append(validationErrors, "logging.max_files must be non-negative")
352 }
353 switch config.Logging.Level {
354 case "trace", "debug", "info", "warn", "error", "fatal", "":
355 default:
356 validationErrors = append(validationErrors, fmt.Sprintf(
357 "logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
358 config.Logging.Level,
359 ))
360 }
361 switch config.Logging.Format {
362 case "text", "json", "console", "":
363 default:
364 validationErrors = append(validationErrors, fmt.Sprintf(
365 "logging.format must be one of: text, json, console (got: %s)",
366 config.Logging.Format,
367 ))
368 }
369 return validationErrors
370}
371
372func validateCEF(config *Config) []string {
373 var validationErrors []string
374
375 switch config.Engine.CEF.CEFRenderStack() {
376 case CEFRenderStackVulkan, CEFRenderStackEGL:
377 default:
378 validationErrors = append(validationErrors, fmt.Sprintf(
379 "engine.cef.render_stack must be one of: vulkan, egl (got: %s)",
380 config.Engine.CEF.RenderStack,
381 ))
382 }
383
384 switch config.Engine.CEF.LogSeverity {
385 case 0, 1, 2, 3, 4, cefLogSeverityDisabled:
386 default:
387 validationErrors = append(validationErrors, fmt.Sprintf(
388 "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)",
389 cefLogSeverityDisabled,
390 config.Engine.CEF.LogSeverity,
391 ))
392 }
393
394 if config.Engine.CEF.WindowlessFrameRate < 0 {
395 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
396 }
397 if config.Engine.CEF.WindowlessFrameRateMax < 0 {
398 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
399 }
400
401 input := config.Engine.CEF.Input
402 if invalidPositiveFloat(input.ScrollWheelMultiplier) {
403 validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0")
404 }
405 if invalidPositiveFloat(input.ScrollPreciseMultiplier) {
406 validationErrors = append(validationErrors, "engine.cef.input.scroll_precise_multiplier must be > 0")
407 }
408 if invalidPositiveFloat(input.ScrollHorizontalMultiplier) {
409 validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0")
410 }
411 if invalidPositiveFloat(input.ScrollVerticalMultiplier) {
412 validationErrors = append(validationErrors, "engine.cef.input.scroll_vertical_multiplier must be > 0")
413 }
414 if input.ScrollMaxDelta < 0 {
415 validationErrors = append(validationErrors, "engine.cef.input.scroll_max_delta must be >= 0")
416 }
417 if invalidPositiveFloat(input.TouchpadNavigationMinDelta) {
418 validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_min_delta must be > 0")
419 }
420 if invalidPositiveFloat(input.TouchpadNavigationMaxVerticalRatio) {
421 validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_max_vertical_ratio must be > 0")
422 }
423
424 return validationErrors
425}
426
427func invalidPositiveFloat(value float64) bool {
428 return value <= 0 || math.IsNaN(value) || math.IsInf(value, 0)
429}
430
431func validateWorkspaceNewPaneURL(config *Config) []string {
432 var validationErrors []string
433
434 if config.Workspace.NewPaneURL == "" {
435 validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
436 return validationErrors
437 }
438
439 validationErrors = append(validationErrors, validateWorkspaceURLValue(
440 "workspace.new_pane_url",
441 config.Workspace.NewPaneURL,
442 )...)
443
444 return validationErrors
445}
446
447func validateWorkspaceURLValue(fieldPath, value string) []string {
448 trimmed := strings.TrimSpace(value)
449 if isRelativeWorkspacePath(trimmed) {
450 return []string{fmt.Sprintf(
451 "%s relative local path cannot be resolved without config file context (got: %s)",
452 fieldPath,
453 value,
454 )}
455 }
456 if isLocalPathLikeWorkspaceURL(trimmed) {
457 if isExistingWorkspacePath(trimmed) {
458 return nil
459 }
460 return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}
461 }
462 candidate := trimmed
463 parsed, err := url.Parse(candidate)
464 if err != nil || parsed.Scheme == "" {
465 candidate = domainurl.Normalize(trimmed)
466 parsed, err = url.Parse(candidate)
467 }
468 if err != nil {
469 return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
470 }
471
472 switch parsed.Scheme {
473 case "http", "https", "dumb", "file", "about":
474 return nil
475 default:
476 return []string{fmt.Sprintf(
477 "%s must use one of: http, https, dumb, file, about (got: %s)",
478 fieldPath,
479 parsed.Scheme,
480 )}
481 }
482}
483
484func isLocalPathLikeWorkspaceURL(value string) bool {
485 return strings.HasPrefix(value, "/") ||
486 strings.HasPrefix(value, "./") ||
487 strings.HasPrefix(value, "../") ||
488 strings.HasPrefix(value, "~/")
489}
490
491func isExistingWorkspacePath(value string) bool {
492 if value == "" || strings.Contains(value, "://") {
493 return false
494 }
495 candidate := value
496 if strings.HasPrefix(candidate, "~/") {
497 homeDir, err := os.UserHomeDir()
498 if err != nil || homeDir == "" {
499 return false
500 }
501 candidate = filepath.Join(homeDir, strings.TrimPrefix(candidate, "~/"))
502 }
503 absPath, err := filepath.Abs(candidate)
504 if err != nil {
505 return false
506 }
507 _, err = os.Stat(absPath)
508 return err == nil
509}
510
511func isRelativeWorkspacePath(value string) bool {
512 if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") ||
513 strings.Contains(value, "://") {
514 return false
515 }
516 if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
517 looksLikeBareRelativeWorkspacePath(value) {
518 return true
519 }
520 return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":")
521}
522
523func looksLikeBareRelativeWorkspacePath(value string) bool {
524 if strings.ContainsAny(value, `/\`) {
525 firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/")
526 return firstSegment == "" || (!strings.Contains(firstSegment, ".") &&
527 firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:"))
528 }
529
530 switch strings.ToLower(filepath.Ext(value)) {
531 case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json",
532 ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml",
533 ".yaml", ".yml":
534 return true
535 default:
536 return false
537 }
538}
539
540func validateOmnibox(config *Config) []string {
541 var validationErrors []string
542 if config.Omnibox.MostVisitedDays < 0 {
543 validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative")
544 }
545 switch config.Omnibox.InitialBehavior {
546 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
547 default:
548 validationErrors = append(validationErrors, fmt.Sprintf(
549 "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)",
550 OmniboxInitialBehaviorRecent,
551 OmniboxInitialBehaviorMostVisited,
552 OmniboxInitialBehaviorNone,
553 config.Omnibox.InitialBehavior,
554 ))
555 }
556 return validationErrors
557}
558
559func validateEngine(config *Config) []string {
560 switch config.Engine.Type {
561 case EngineTypeCEF, EngineTypeWebKit:
562 return nil
563 default:
564 return []string{fmt.Sprintf(
565 "engine.type must be one of: cef, webkit (got: %s)",
566 config.Engine.Type,
567 )}
568 }
569}
570
571func validateRendering(config *Config) []string {
572 var validationErrors []string
573
574 switch config.Engine.WebKit.GSKRenderer {
575 case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
576 default:
577 validationErrors = append(
578 validationErrors,
579 fmt.Sprintf(
580 "engine.webkit.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
581 config.Engine.WebKit.GSKRenderer,
582 ),
583 )
584 }
585
586 switch config.Engine.WebKit.GLRenderingMode {
587 case GLRenderingModeAuto, GLRenderingModeGLES2, GLRenderingModeGL3, GLRenderingModeNone, "":
588 default:
589 validationErrors = append(
590 validationErrors,
591 fmt.Sprintf(
592 "engine.webkit.gl_rendering_mode must be one of: auto, gles2, gl3, none (got: %s)",
593 config.Engine.WebKit.GLRenderingMode,
594 ),
595 )
596 }
597
598 if config.Engine.WebKit.GStreamerDebugLevel < 0 || config.Engine.WebKit.GStreamerDebugLevel > 5 {
599 validationErrors = append(validationErrors, fmt.Sprintf(
600 "engine.webkit.gstreamer_debug_level must be between 0 and 5 (got: %d)",
601 config.Engine.WebKit.GStreamerDebugLevel,
602 ))
603 }
604
605 if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
606 validationErrors = append(validationErrors,
607 "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",
608 )
609 }
610
611 return validationErrors
612}
613
614func validatePrivacy(config *Config) []string {
615 switch config.Engine.CookiePolicy {
616 case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
617 return nil
618 default:
619 return []string{fmt.Sprintf(
620 "engine.cookie_policy must be one of: always, no_third_party, never (got: %s)",
621 config.Engine.CookiePolicy,
622 )}
623 }
624}
625
626func validateColorScheme(config *Config) []string {
627 switch config.Appearance.ColorScheme {
628 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
629 return nil
630 default:
631 return []string{fmt.Sprintf(
632 "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
633 config.Appearance.ColorScheme,
634 )}
635 }
636}
637
638func validateSession(config *Config) []string {
639 var validationErrors []string
640 if config.Session.MaxExitedSessions < 0 {
641 validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
642 }
643 if config.Session.MaxExitedSessionAgeDays < 0 {
644 validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
645 }
646 if config.Session.SnapshotIntervalMs < 0 {
647 validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
648 }
649 return validationErrors
650}
651
652func validatePerformanceProfile(config *Config) []string {
653 var validationErrors []string
654
655 // Performance profiles and webkit-specific tuning only apply to the webkit engine.
656 engineType := config.Engine.ResolveEngineType()
657 if engineType != "webkit" {
658 return nil
659 }
660
661 // Validate profile name
662 if !IsValidPerformanceProfile(config.Engine.Profile) {
663 validationErrors = append(validationErrors, fmt.Sprintf(
664 "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
665 config.Engine.Profile,
666 ))
667 }
668
669 // When profile is not "custom", warn if individual fields are set
670 // (they will be ignored in favor of profile-computed values)
671 if config.Engine.Profile != ProfileCustom && config.Engine.Profile != "" {
672 perfCfg := PerformanceConfigFromEngine(&config.Engine)
673 if HasCustomPerformanceFields(&perfCfg) {
674 validationErrors = append(validationErrors,
675 "engine performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
676 "set profile = \"custom\" to use individual field values",
677 )
678 }
679 }
680
681 // Validate memory pressure threshold ordering: WebKit requires conservative < strict
682 webCons := config.Engine.WebKit.WebProcessMemoryConservativeThreshold
683 webStrict := config.Engine.WebKit.WebProcessMemoryStrictThreshold
684 if webCons > 0 && webStrict > 0 && webCons >= webStrict {
685 validationErrors = append(validationErrors,
686 "engine.webkit.web_process_memory_conservative_threshold must be less than "+
687 "web_process_memory_strict_threshold when both are set",
688 )
689 }
690
691 netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold
692 netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold
693 if netCons > 0 && netStrict > 0 && netCons >= netStrict {
694 validationErrors = append(validationErrors,
695 "engine.webkit.network_process_memory_conservative_threshold must be less than "+
696 "network_process_memory_strict_threshold when both are set",
697 )
698 }
699
700 return validationErrors
701}