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 switch config.Workspace.Styling.ModeLegend {
227 case "always", "delay", "off":
228 default:
229 validationErrors = append(validationErrors, "workspace.styling.mode_legend must be always, delay, or off")
230 }
231 if config.Workspace.Styling.ModeLegendDelayMs < 0 {
232 validationErrors = append(validationErrors, "workspace.styling.mode_legend_delay_ms must be non-negative")
233 }
234 return validationErrors
235}
236
237func validatePaneMode(config *Config) []string {
238 return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)
239}
240
241func validateTabBar(config *Config) []string {
242 switch config.Workspace.TabBarPosition {
243 case "top", "bottom":
244 return nil
245 default:
246 return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
247 }
248}
249
250func validateTabMode(config *Config) []string {
251 return validateModalModeActions("tab_mode", config.Workspace.TabMode.TimeoutMilliseconds, config.Workspace.TabMode.Actions)
252}
253
254func validateVimMode(config *Config) []string {
255 var validationErrors []string
256 if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 {
257 validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative")
258 }
259 validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon(
260 "vim_mode",
261 config.Workspace.VimMode.TimeoutMilliseconds,
262 config.Workspace.VimMode.Actions,
263 canonicalizeVimModeSequenceKey,
264 )...)
265 return validationErrors
266}
267
268// validateModalModeActions shared timeout/empty-actions/duplicate-key checks for
269// pane, tab, and page modal modes. Messages stay path-exact via modePath.
270func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {
271 return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil)
272}
273
274func validateModalModeActionsWithKeyCanon(
275 modePath string,
276 timeoutMS int,
277 actions map[string]ActionBinding,
278 keyCanon func(string) (string, error),
279) []string {
280 var validationErrors []string
281 if timeoutMS < 0 {
282 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))
283 }
284 if len(actions) == 0 {
285 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
286 }
287
288 if keyCanon == nil {
289 keyCanon = func(key string) (string, error) { return key, nil }
290 }
291
292 actionNames := make([]string, 0, len(actions))
293 for action := range actions {
294 actionNames = append(actionNames, action)
295 }
296 sort.Strings(actionNames)
297
298 seenSequence := make(map[string]string)
299 for _, action := range actionNames {
300 binding := actions[action]
301 if len(binding.Keys) == 0 {
302 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
303 continue
304 }
305 keys := append([]string(nil), binding.Keys...)
306 sort.Strings(keys)
307 for _, key := range keys {
308 canonical, err := keyCanon(key)
309 if err != nil {
310 validationErrors = append(validationErrors, fmt.Sprintf(
311 "workspace.%s.actions.%s has invalid key binding '%s': %v",
312 modePath,
313 action,
314 key,
315 err,
316 ))
317 continue
318 }
319 if modePath == "vim_mode" {
320 sequence, parseErr := vimkeys.ParseBinding(canonical)
321 if parseErr == nil {
322 if len(sequence) > 0 && isVimCountStartKey(sequence[0]) {
323 validationErrors = append(validationErrors, fmt.Sprintf(
324 "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'",
325 modePath,
326 action,
327 canonical,
328 ))
329 }
330 legacyExit := action == "confirm" || action == "cancel"
331 if !legacyExit {
332 for _, key := range sequence {
333 if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") {
334 validationErrors = append(validationErrors, fmt.Sprintf(
335 "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions",
336 modePath,
337 action,
338 ))
339 break
340 }
341 }
342 }
343 }
344 }
345 if existingAction, exists := seenSequence[canonical]; exists {
346 validationErrors = append(validationErrors, fmt.Sprintf(
347 "duplicate key binding '%s' found in %s actions '%s' and '%s'",
348 canonical,
349 modePath,
350 existingAction,
351 action,
352 ))
353 }
354 seenSequence[canonical] = action
355 }
356 }
357 return validationErrors
358}
359
360func isVimCountStartKey(key vimkeys.Key) bool {
361 return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
362}
363
364func canonicalizeVimModeBinding(key string) string {
365 switch strings.ToLower(strings.TrimSpace(key)) {
366 case "enter":
367 return "<Return>"
368 case "escape":
369 return "<Escape>"
370 default:
371 return key
372 }
373}
374
375func canonicalizeVimModeSequenceKey(key string) (string, error) {
376 seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key))
377 if err != nil {
378 return "", err
379 }
380 return seq.String(), nil
381}
382
383func validateFloatingPane(config *Config) []string {
384 var validationErrors []string
385 seenProfileKeys := make(map[string]string)
386
387 if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
388 validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
389 }
390
391 if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
392 validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
393 }
394
395 profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles))
396 for profileName := range config.Workspace.FloatingPane.Profiles {
397 profileNames = append(profileNames, profileName)
398 }
399 sort.Strings(profileNames)
400
401 for _, profileName := range profileNames {
402 profile := config.Workspace.FloatingPane.Profiles[profileName]
403 if strings.TrimSpace(profile.URL) == "" {
404 validationErrors = append(validationErrors, fmt.Sprintf(
405 "workspace.floating_pane.profiles.%s.url is required",
406 profileName,
407 ))
408 } else {
409 validationErrors = append(validationErrors, validateWorkspaceURLValue(
410 fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
411 profile.URL,
412 )...)
413 }
414 if len(profile.Keys) == 0 {
415 validationErrors = append(validationErrors, fmt.Sprintf(
416 "workspace.floating_pane.profiles.%s.keys must have at least one entry",
417 profileName,
418 ))
419 }
420
421 for _, key := range profile.Keys {
422 normalizedKey := normalizeBindingKeyString(key)
423 if normalizedKey == "" {
424 validationErrors = append(validationErrors, fmt.Sprintf(
425 "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
426 profileName,
427 key,
428 ))
429 continue
430 }
431
432 if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
433 validationErrors = append(validationErrors, fmt.Sprintf(
434 "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
435 profileName,
436 key,
437 existingProfile,
438 ))
439 continue
440 }
441
442 seenProfileKeys[normalizedKey] = profileName
443 }
444 }
445
446 return validationErrors
447}
448
449func normalizeBindingKeyString(key string) string {
450 normalized := strings.ToLower(strings.TrimSpace(key))
451 if normalized == "" {
452 return ""
453 }
454 return strings.Join(strings.Fields(normalized), "")
455}
456
457func validateLogging(config *Config) []string {
458 var validationErrors []string
459 if config.Logging.MaxAge < 0 {
460 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
461 }
462 if config.Logging.MaxFiles < 0 {
463 validationErrors = append(validationErrors, "logging.max_files must be non-negative")
464 }
465 switch config.Logging.Level {
466 case "trace", "debug", "info", "warn", "error", "fatal", "":
467 default:
468 validationErrors = append(validationErrors, fmt.Sprintf(
469 "logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
470 config.Logging.Level,
471 ))
472 }
473 switch config.Logging.Format {
474 case "text", "json", "console", "":
475 default:
476 validationErrors = append(validationErrors, fmt.Sprintf(
477 "logging.format must be one of: text, json, console (got: %s)",
478 config.Logging.Format,
479 ))
480 }
481 return validationErrors
482}
483
484func validateCEF(config *Config) []string {
485 var validationErrors []string
486
487 switch config.Engine.CEF.CEFRenderStack() {
488 case CEFRenderStackVulkan, CEFRenderStackEGL:
489 default:
490 validationErrors = append(validationErrors, fmt.Sprintf(
491 "engine.cef.render_stack must be one of: vulkan, egl (got: %s)",
492 config.Engine.CEF.RenderStack,
493 ))
494 }
495
496 switch config.Engine.CEF.LogSeverity {
497 case 0, 1, 2, 3, 4, cefLogSeverityDisabled:
498 default:
499 validationErrors = append(validationErrors, fmt.Sprintf(
500 "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)",
501 cefLogSeverityDisabled,
502 config.Engine.CEF.LogSeverity,
503 ))
504 }
505
506 if config.Engine.CEF.WindowlessFrameRate < 0 {
507 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
508 }
509 if config.Engine.CEF.WindowlessFrameRateMax < 0 {
510 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
511 }
512 if config.Engine.CEF.IdleRuntimeTimeoutMs < 0 || config.Engine.CEF.IdleRuntimeTimeoutMs > 300000 {
513 validationErrors = append(validationErrors, "engine.cef.idle_runtime_timeout_ms must be within 0..300000")
514 }
515
516 input := config.Engine.CEF.Input
517 if invalidPositiveFloat(input.ScrollWheelMultiplier) {
518 validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0")
519 }
520 if invalidPositiveFloat(input.ScrollPreciseMultiplier) {
521 validationErrors = append(validationErrors, "engine.cef.input.scroll_precise_multiplier must be > 0")
522 }
523 if invalidPositiveFloat(input.ScrollHorizontalMultiplier) {
524 validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0")
525 }
526 if invalidPositiveFloat(input.ScrollVerticalMultiplier) {
527 validationErrors = append(validationErrors, "engine.cef.input.scroll_vertical_multiplier must be > 0")
528 }
529 if input.ScrollMaxDelta < 0 {
530 validationErrors = append(validationErrors, "engine.cef.input.scroll_max_delta must be >= 0")
531 }
532 if invalidPositiveFloat(input.TouchpadNavigationMinDelta) {
533 validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_min_delta must be > 0")
534 }
535 if invalidPositiveFloat(input.TouchpadNavigationMaxVerticalRatio) {
536 validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_max_vertical_ratio must be > 0")
537 }
538
539 return validationErrors
540}
541
542func invalidPositiveFloat(value float64) bool {
543 return value <= 0 || math.IsNaN(value) || math.IsInf(value, 0)
544}
545
546func validateWorkspaceNewPaneURL(config *Config) []string {
547 var validationErrors []string
548
549 if config.Workspace.NewPaneURL == "" {
550 validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
551 return validationErrors
552 }
553
554 validationErrors = append(validationErrors, validateWorkspaceURLValue(
555 "workspace.new_pane_url",
556 config.Workspace.NewPaneURL,
557 )...)
558
559 return validationErrors
560}
561
562func validateWorkspaceURLValue(fieldPath, value string) []string {
563 trimmed := strings.TrimSpace(value)
564 if isRelativeWorkspacePath(trimmed) {
565 return []string{fmt.Sprintf(
566 "%s relative local path cannot be resolved without config file context (got: %s)",
567 fieldPath,
568 value,
569 )}
570 }
571 if isLocalPathLikeWorkspaceURL(trimmed) {
572 if isExistingWorkspacePath(trimmed) {
573 return nil
574 }
575 return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}
576 }
577 candidate := trimmed
578 parsed, err := url.Parse(candidate)
579 if err != nil || parsed.Scheme == "" {
580 candidate = domainurl.Normalize(trimmed)
581 parsed, err = url.Parse(candidate)
582 }
583 if err != nil {
584 return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
585 }
586
587 switch parsed.Scheme {
588 case "http", "https", "dumb", "file", "about":
589 return nil
590 default:
591 return []string{fmt.Sprintf(
592 "%s must use one of: http, https, dumb, file, about (got: %s)",
593 fieldPath,
594 parsed.Scheme,
595 )}
596 }
597}
598
599func isLocalPathLikeWorkspaceURL(value string) bool {
600 return strings.HasPrefix(value, "/") ||
601 strings.HasPrefix(value, "./") ||
602 strings.HasPrefix(value, "../") ||
603 strings.HasPrefix(value, "~/")
604}
605
606func isExistingWorkspacePath(value string) bool {
607 if value == "" || strings.Contains(value, "://") {
608 return false
609 }
610 candidate := value
611 if strings.HasPrefix(candidate, "~/") {
612 homeDir, err := os.UserHomeDir()
613 if err != nil || homeDir == "" {
614 return false
615 }
616 candidate = filepath.Join(homeDir, strings.TrimPrefix(candidate, "~/"))
617 }
618 absPath, err := filepath.Abs(candidate)
619 if err != nil {
620 return false
621 }
622 _, err = os.Stat(absPath)
623 return err == nil
624}
625
626func isRelativeWorkspacePath(value string) bool {
627 if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") ||
628 strings.Contains(value, "://") {
629 return false
630 }
631 if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
632 looksLikeBareRelativeWorkspacePath(value) {
633 return true
634 }
635 return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":")
636}
637
638func looksLikeBareRelativeWorkspacePath(value string) bool {
639 if strings.ContainsAny(value, `/\`) {
640 firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/")
641 return firstSegment == "" || (!strings.Contains(firstSegment, ".") &&
642 firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:"))
643 }
644
645 switch strings.ToLower(filepath.Ext(value)) {
646 case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json",
647 ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml",
648 ".yaml", ".yml":
649 return true
650 default:
651 return false
652 }
653}
654
655func validateOmnibox(config *Config) []string {
656 var validationErrors []string
657 switch config.Omnibox.Style {
658 case "multiplexer", "command", "minimal":
659 default:
660 validationErrors = append(validationErrors, fmt.Sprintf(
661 "omnibox.style must be one of: multiplexer, command, minimal (got: %s)",
662 config.Omnibox.Style,
663 ))
664 }
665 if config.Omnibox.MaxHistoryDays < 0 {
666 validationErrors = append(validationErrors, "omnibox.max_history_days must be non-negative")
667 }
668 switch config.Omnibox.InitialBehavior {
669 case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
670 default:
671 validationErrors = append(validationErrors, fmt.Sprintf(
672 "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)",
673 OmniboxInitialBehaviorRecent,
674 OmniboxInitialBehaviorMostVisited,
675 OmniboxInitialBehaviorNone,
676 config.Omnibox.InitialBehavior,
677 ))
678 }
679 return validationErrors
680}
681
682func validateEngine(config *Config) []string {
683 switch config.Engine.Type {
684 case EngineTypeCEF, EngineTypeWebKit:
685 return nil
686 default:
687 return []string{fmt.Sprintf(
688 "engine.type must be one of: cef, webkit (got: %s)",
689 config.Engine.Type,
690 )}
691 }
692}
693
694func validateRendering(config *Config) []string {
695 var validationErrors []string
696
697 switch config.Engine.WebKit.GSKRenderer {
698 case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
699 default:
700 validationErrors = append(
701 validationErrors,
702 fmt.Sprintf(
703 "engine.webkit.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
704 config.Engine.WebKit.GSKRenderer,
705 ),
706 )
707 }
708
709 switch config.Engine.WebKit.GLRenderingMode {
710 case GLRenderingModeAuto, GLRenderingModeGLES2, GLRenderingModeGL3, GLRenderingModeNone, "":
711 default:
712 validationErrors = append(
713 validationErrors,
714 fmt.Sprintf(
715 "engine.webkit.gl_rendering_mode must be one of: auto, gles2, gl3, none (got: %s)",
716 config.Engine.WebKit.GLRenderingMode,
717 ),
718 )
719 }
720
721 if config.Engine.WebKit.GStreamerDebugLevel < 0 || config.Engine.WebKit.GStreamerDebugLevel > 5 {
722 validationErrors = append(validationErrors, fmt.Sprintf(
723 "engine.webkit.gstreamer_debug_level must be between 0 and 5 (got: %d)",
724 config.Engine.WebKit.GStreamerDebugLevel,
725 ))
726 }
727
728 if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
729 validationErrors = append(validationErrors,
730 "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",
731 )
732 }
733
734 return validationErrors
735}
736
737func validatePrivacy(config *Config) []string {
738 switch config.Engine.CookiePolicy {
739 case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
740 return nil
741 default:
742 return []string{fmt.Sprintf(
743 "engine.cookie_policy must be one of: always, no_third_party, never (got: %s)",
744 config.Engine.CookiePolicy,
745 )}
746 }
747}
748
749func validateColorScheme(config *Config) []string {
750 switch config.Appearance.ColorScheme {
751 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
752 return nil
753 default:
754 return []string{fmt.Sprintf(
755 "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
756 config.Appearance.ColorScheme,
757 )}
758 }
759}
760
761func validateSession(config *Config) []string {
762 var validationErrors []string
763 if config.Session.MaxExitedSessions < 0 {
764 validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
765 }
766 if config.Session.MaxExitedSessionAgeDays < 0 {
767 validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
768 }
769 if config.Session.SnapshotIntervalMs < 0 {
770 validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
771 }
772 return validationErrors
773}
774
775func validatePerformanceProfile(config *Config) []string {
776 var validationErrors []string
777
778 // Performance profiles and webkit-specific tuning only apply to the webkit engine.
779 engineType := config.Engine.ResolveEngineType()
780 if engineType != "webkit" {
781 return nil
782 }
783
784 // Validate profile name
785 if !IsValidPerformanceProfile(config.Engine.Profile) {
786 validationErrors = append(validationErrors, fmt.Sprintf(
787 "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
788 config.Engine.Profile,
789 ))
790 }
791
792 // When profile is not "custom", warn if individual fields are set
793 // (they will be ignored in favor of profile-computed values)
794 if config.Engine.Profile != ProfileCustom && config.Engine.Profile != "" {
795 perfCfg := PerformanceConfigFromEngine(&config.Engine)
796 if HasCustomPerformanceFields(&perfCfg) {
797 validationErrors = append(validationErrors,
798 "engine performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
799 "set profile = \"custom\" to use individual field values",
800 )
801 }
802 }
803
804 // Validate memory pressure threshold ordering: WebKit requires conservative < strict
805 webCons := config.Engine.WebKit.WebProcessMemoryConservativeThreshold
806 webStrict := config.Engine.WebKit.WebProcessMemoryStrictThreshold
807 if webCons > 0 && webStrict > 0 && webCons >= webStrict {
808 validationErrors = append(validationErrors,
809 "engine.webkit.web_process_memory_conservative_threshold must be less than "+
810 "web_process_memory_strict_threshold when both are set",
811 )
812 }
813
814 netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold
815 netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold
816 if netCons > 0 && netStrict > 0 && netCons >= netStrict {
817 validationErrors = append(validationErrors,
818 "engine.webkit.network_process_memory_conservative_threshold must be less than "+
819 "network_process_memory_strict_threshold when both are set",
820 )
821 }
822
823 return validationErrors
824}