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