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