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