package config import ( "fmt" "math" "net/url" "os" "path/filepath" "sort" "strings" domainurl "github.com/bnema/dumber/internal/domain/url" domainvalidation "github.com/bnema/dumber/internal/domain/validation" "github.com/bnema/dumber/internal/domain/vimkeys" ) const cefLogSeverityDisabled = 99 // validateConfig performs comprehensive validation of configuration values func validateConfig(config *Config) error { var validationErrors []string validationErrors = append(validationErrors, validateHistory(config)...) validationErrors = append(validationErrors, validateDmenu(config)...) validationErrors = append(validationErrors, validateAppearance(config)...) validationErrors = append(validationErrors, validateSearchEngine(config)...) validationErrors = append(validationErrors, validatePopups(config)...) validationErrors = append(validationErrors, validateExternalLinks(config)...) validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) validationErrors = append(validationErrors, validatePaneMode(config)...) validationErrors = append(validationErrors, validateTabBar(config)...) validationErrors = append(validationErrors, validateTabMode(config)...) validationErrors = append(validationErrors, validateVimMode(config)...) validationErrors = append(validationErrors, validateFloatingPane(config)...) validationErrors = append(validationErrors, validateLogging(config)...) validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) validationErrors = append(validationErrors, validateOmnibox(config)...) validationErrors = append(validationErrors, validateEngine(config)...) validationErrors = append(validationErrors, validateRendering(config)...) validationErrors = append(validationErrors, validatePrivacy(config)...) validationErrors = append(validationErrors, validateColorScheme(config)...) validationErrors = append(validationErrors, validateSession(config)...) validationErrors = append(validationErrors, validatePerformanceProfile(config)...) validationErrors = append(validationErrors, validateCEF(config)...) // If there are validation errors, return them if len(validationErrors) > 0 { return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) } return nil } func validateHistory(config *Config) []string { var validationErrors []string if config.History.MaxEntries < 0 { validationErrors = append(validationErrors, "history.max_entries must be non-negative") } if config.History.RetentionPeriodDays < 0 { validationErrors = append(validationErrors, "history.retention_period_days must be non-negative") } if config.History.CleanupIntervalDays < 0 { validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative") } return validationErrors } func validateDmenu(config *Config) []string { if config.Dmenu.MaxHistoryDays < 0 { return []string{"dmenu.max_history_days must be non-negative"} } return nil } func validateAppearance(config *Config) []string { var validationErrors []string if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 { validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") } validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...) validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...) validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.gtk_font", config.Appearance.GtkFont)...) validationErrors = append( validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)..., ) validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( "appearance.light_palette", config.Appearance.LightPalette.Background, config.Appearance.LightPalette.Surface, config.Appearance.LightPalette.SurfaceVariant, config.Appearance.LightPalette.Text, config.Appearance.LightPalette.Muted, config.Appearance.LightPalette.Accent, config.Appearance.LightPalette.Border, )...) validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( "appearance.dark_palette", config.Appearance.DarkPalette.Background, config.Appearance.DarkPalette.Surface, config.Appearance.DarkPalette.SurfaceVariant, config.Appearance.DarkPalette.Text, config.Appearance.DarkPalette.Muted, config.Appearance.DarkPalette.Accent, config.Appearance.DarkPalette.Border, )...) if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 { validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0") } if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 { validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0") } if config.SidebarWidth != 0 && (config.SidebarWidth < 280 || config.SidebarWidth > 380) { validationErrors = append(validationErrors, "sidebar_width must be between 280 and 380, or 0 for default") } validationErrors = append(validationErrors, validateExternalTheme(config)...) return validationErrors } func validateExternalTheme(config *Config) []string { externalTheme := config.Appearance.ExternalTheme if !externalTheme.Enabled { return nil } var validationErrors []string if externalTheme.Provider != defaultExternalThemeProvider { validationErrors = append(validationErrors, "appearance.external_theme.provider must be one of: noctalia") } if !isSupportedExternalThemeFormat(externalTheme.Format) { validationErrors = append(validationErrors, "appearance.external_theme.format must be one of: colors-json, dumber-json") } if strings.TrimSpace(externalTheme.Path) == "" { validationErrors = append(validationErrors, "appearance.external_theme.path is required when external_theme is enabled") } return validationErrors } func isSupportedExternalThemeFormat(format string) bool { switch format { case defaultExternalThemeFormat, defaultExternalThemeTemplateFormat: return true default: return false } } func validateSearchEngine(config *Config) []string { if config.DefaultSearchEngine == "" { return []string{"default_search_engine cannot be empty"} } if !strings.Contains(config.DefaultSearchEngine, "%s") { return []string{"default_search_engine must contain %s placeholder for the search query"} } return nil } func validatePopups(config *Config) []string { var validationErrors []string // Validate against BrowsingContexts (canonical field). // Popups is a runtime alias synced during normalization. switch config.Workspace.BrowsingContexts.Behavior { case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed: default: validationErrors = append(validationErrors, fmt.Sprintf( "workspace.browsing_contexts.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.BrowsingContexts.Behavior, )) } if config.Workspace.BrowsingContexts.Behavior == PopupBehaviorSplit { switch config.Workspace.BrowsingContexts.Placement { case "right", "left", "top", "bottom": default: validationErrors = append(validationErrors, fmt.Sprintf( "workspace.browsing_contexts.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.BrowsingContexts.Placement, )) } } switch config.Workspace.BrowsingContexts.BlankTargetBehavior { case "split", "stacked", "tabbed": default: validationErrors = append(validationErrors, fmt.Sprintf( "workspace.browsing_contexts.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.BrowsingContexts.BlankTargetBehavior, )) } return validationErrors } func validateExternalLinks(config *Config) []string { var validationErrors []string switch config.Workspace.ExternalLinks.Behavior { case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked: default: validationErrors = append(validationErrors, fmt.Sprintf( "workspace.external_links.behavior must be one of: windowed, tabbed, split, stacked (got: %s)", config.Workspace.ExternalLinks.Behavior, )) } switch config.Workspace.ExternalLinks.Placement { case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom: default: validationErrors = append(validationErrors, fmt.Sprintf( "workspace.external_links.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.ExternalLinks.Placement, )) } return validationErrors } func validateWorkspaceStyling(config *Config) []string { var validationErrors []string if config.Workspace.Styling.BorderWidth < 0 { validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") } if config.Workspace.Styling.ModeBorderWidth < 0 { validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative") } if config.Workspace.Styling.TransitionDuration < 0 { validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") } return validationErrors } func validatePaneMode(config *Config) []string { return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions) } func validateTabBar(config *Config) []string { switch config.Workspace.TabBarPosition { case "top", "bottom": return nil default: return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)} } } func validateTabMode(config *Config) []string { return validateModalModeActions("tab_mode", config.Workspace.TabMode.TimeoutMilliseconds, config.Workspace.TabMode.Actions) } func validateVimMode(config *Config) []string { var validationErrors []string if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 { validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative") } validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon( "vim_mode", config.Workspace.VimMode.TimeoutMilliseconds, config.Workspace.VimMode.Actions, canonicalizeVimModeSequenceKey, )...) return validationErrors } // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for // pane, tab, and page modal modes. Messages stay path-exact via modePath. func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string { return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil) } func validateModalModeActionsWithKeyCanon( modePath string, timeoutMS int, actions map[string]ActionBinding, keyCanon func(string) (string, error), ) []string { var validationErrors []string if timeoutMS < 0 { validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath)) } if len(actions) == 0 { validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) } if keyCanon == nil { keyCanon = func(key string) (string, error) { return key, nil } } actionNames := make([]string, 0, len(actions)) for action := range actions { actionNames = append(actionNames, action) } sort.Strings(actionNames) seenSequence := make(map[string]string) for _, action := range actionNames { binding := actions[action] if len(binding.Keys) == 0 { validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action)) continue } keys := append([]string(nil), binding.Keys...) sort.Strings(keys) for _, key := range keys { canonical, err := keyCanon(key) if err != nil { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err, )) continue } if modePath == "vim_mode" { sequence, parseErr := vimkeys.ParseBinding(canonical) if parseErr == nil { if len(sequence) > 0 && isVimCountStartKey(sequence[0]) { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'", modePath, action, canonical, )) } legacyExit := action == "confirm" || action == "cancel" if !legacyExit { for _, key := range sequence { if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions", modePath, action, )) break } } } } } if existingAction, exists := seenSequence[canonical]; exists { validationErrors = append(validationErrors, fmt.Sprintf( "duplicate key binding '%s' found in %s actions '%s' and '%s'", canonical, modePath, existingAction, action, )) } seenSequence[canonical] = action } } return validationErrors } func isVimCountStartKey(key vimkeys.Key) bool { return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9' } func canonicalizeVimModeBinding(key string) string { switch strings.ToLower(strings.TrimSpace(key)) { case "enter": return "" case "escape": return "" default: return key } } func canonicalizeVimModeSequenceKey(key string) (string, error) { seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key)) if err != nil { return "", err } return seq.String(), nil } func validateFloatingPane(config *Config) []string { var validationErrors []string seenProfileKeys := make(map[string]string) if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 { validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]") } if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 { validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]") } profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles)) for profileName := range config.Workspace.FloatingPane.Profiles { profileNames = append(profileNames, profileName) } sort.Strings(profileNames) for _, profileName := range profileNames { profile := config.Workspace.FloatingPane.Profiles[profileName] if strings.TrimSpace(profile.URL) == "" { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.floating_pane.profiles.%s.url is required", profileName, )) } else { validationErrors = append(validationErrors, validateWorkspaceURLValue( fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName), profile.URL, )...) } if len(profile.Keys) == 0 { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.floating_pane.profiles.%s.keys must have at least one entry", profileName, )) } for _, key := range profile.Keys { normalizedKey := normalizeBindingKeyString(key) if normalizedKey == "" { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q", profileName, key, )) continue } if existingProfile, exists := seenProfileKeys[normalizedKey]; exists { validationErrors = append(validationErrors, fmt.Sprintf( "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q", profileName, key, existingProfile, )) continue } seenProfileKeys[normalizedKey] = profileName } } return validationErrors } func normalizeBindingKeyString(key string) string { normalized := strings.ToLower(strings.TrimSpace(key)) if normalized == "" { return "" } return strings.Join(strings.Fields(normalized), "") } func validateLogging(config *Config) []string { var validationErrors []string if config.Logging.MaxAge < 0 { validationErrors = append(validationErrors, "logging.max_age must be non-negative") } if config.Logging.MaxFiles < 0 { validationErrors = append(validationErrors, "logging.max_files must be non-negative") } switch config.Logging.Level { case "trace", "debug", "info", "warn", "error", "fatal", "": default: validationErrors = append(validationErrors, fmt.Sprintf( "logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)", config.Logging.Level, )) } switch config.Logging.Format { case "text", "json", "console", "": default: validationErrors = append(validationErrors, fmt.Sprintf( "logging.format must be one of: text, json, console (got: %s)", config.Logging.Format, )) } return validationErrors } func validateCEF(config *Config) []string { var validationErrors []string switch config.Engine.CEF.CEFRenderStack() { case CEFRenderStackVulkan, CEFRenderStackEGL: default: validationErrors = append(validationErrors, fmt.Sprintf( "engine.cef.render_stack must be one of: vulkan, egl (got: %s)", config.Engine.CEF.RenderStack, )) } switch config.Engine.CEF.LogSeverity { case 0, 1, 2, 3, 4, cefLogSeverityDisabled: default: validationErrors = append(validationErrors, fmt.Sprintf( "engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)", cefLogSeverityDisabled, config.Engine.CEF.LogSeverity, )) } if config.Engine.CEF.WindowlessFrameRate < 0 { validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") } if config.Engine.CEF.WindowlessFrameRateMax < 0 { validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0") } input := config.Engine.CEF.Input if invalidPositiveFloat(input.ScrollWheelMultiplier) { validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0") } if invalidPositiveFloat(input.ScrollPreciseMultiplier) { validationErrors = append(validationErrors, "engine.cef.input.scroll_precise_multiplier must be > 0") } if invalidPositiveFloat(input.ScrollHorizontalMultiplier) { validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0") } if invalidPositiveFloat(input.ScrollVerticalMultiplier) { validationErrors = append(validationErrors, "engine.cef.input.scroll_vertical_multiplier must be > 0") } if input.ScrollMaxDelta < 0 { validationErrors = append(validationErrors, "engine.cef.input.scroll_max_delta must be >= 0") } if invalidPositiveFloat(input.TouchpadNavigationMinDelta) { validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_min_delta must be > 0") } if invalidPositiveFloat(input.TouchpadNavigationMaxVerticalRatio) { validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_max_vertical_ratio must be > 0") } return validationErrors } func invalidPositiveFloat(value float64) bool { return value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) } func validateWorkspaceNewPaneURL(config *Config) []string { var validationErrors []string if config.Workspace.NewPaneURL == "" { validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty") return validationErrors } validationErrors = append(validationErrors, validateWorkspaceURLValue( "workspace.new_pane_url", config.Workspace.NewPaneURL, )...) return validationErrors } func validateWorkspaceURLValue(fieldPath, value string) []string { trimmed := strings.TrimSpace(value) if isRelativeWorkspacePath(trimmed) { return []string{fmt.Sprintf( "%s relative local path cannot be resolved without config file context (got: %s)", fieldPath, value, )} } if isLocalPathLikeWorkspaceURL(trimmed) { if isExistingWorkspacePath(trimmed) { return nil } return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)} } candidate := trimmed parsed, err := url.Parse(candidate) if err != nil || parsed.Scheme == "" { candidate = domainurl.Normalize(trimmed) parsed, err = url.Parse(candidate) } if err != nil { return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)} } switch parsed.Scheme { case "http", "https", "dumb", "file", "about": return nil default: return []string{fmt.Sprintf( "%s must use one of: http, https, dumb, file, about (got: %s)", fieldPath, parsed.Scheme, )} } } func isLocalPathLikeWorkspaceURL(value string) bool { return strings.HasPrefix(value, "/") || strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") || strings.HasPrefix(value, "~/") } func isExistingWorkspacePath(value string) bool { if value == "" || strings.Contains(value, "://") { return false } candidate := value if strings.HasPrefix(candidate, "~/") { homeDir, err := os.UserHomeDir() if err != nil || homeDir == "" { return false } candidate = filepath.Join(homeDir, strings.TrimPrefix(candidate, "~/")) } absPath, err := filepath.Abs(candidate) if err != nil { return false } _, err = os.Stat(absPath) return err == nil } func isRelativeWorkspacePath(value string) bool { if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") || strings.Contains(value, "://") { return false } if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") || looksLikeBareRelativeWorkspacePath(value) { return true } return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":") } func looksLikeBareRelativeWorkspacePath(value string) bool { if strings.ContainsAny(value, `/\`) { firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/") return firstSegment == "" || (!strings.Contains(firstSegment, ".") && firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:")) } switch strings.ToLower(filepath.Ext(value)) { case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json", ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml", ".yaml", ".yml": return true default: return false } } func validateOmnibox(config *Config) []string { var validationErrors []string if config.Omnibox.MostVisitedDays < 0 { validationErrors = append(validationErrors, "omnibox.most_visited_days must be non-negative") } switch config.Omnibox.InitialBehavior { case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone: default: validationErrors = append(validationErrors, fmt.Sprintf( "omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)", OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone, config.Omnibox.InitialBehavior, )) } return validationErrors } func validateEngine(config *Config) []string { switch config.Engine.Type { case EngineTypeCEF, EngineTypeWebKit: return nil default: return []string{fmt.Sprintf( "engine.type must be one of: cef, webkit (got: %s)", config.Engine.Type, )} } } func validateRendering(config *Config) []string { var validationErrors []string switch config.Engine.WebKit.GSKRenderer { case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "": default: validationErrors = append( validationErrors, fmt.Sprintf( "engine.webkit.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)", config.Engine.WebKit.GSKRenderer, ), ) } switch config.Engine.WebKit.GLRenderingMode { case GLRenderingModeAuto, GLRenderingModeGLES2, GLRenderingModeGL3, GLRenderingModeNone, "": default: validationErrors = append( validationErrors, fmt.Sprintf( "engine.webkit.gl_rendering_mode must be one of: auto, gles2, gl3, none (got: %s)", config.Engine.WebKit.GLRenderingMode, ), ) } if config.Engine.WebKit.GStreamerDebugLevel < 0 || config.Engine.WebKit.GStreamerDebugLevel > 5 { validationErrors = append(validationErrors, fmt.Sprintf( "engine.webkit.gstreamer_debug_level must be between 0 and 5 (got: %d)", config.Engine.WebKit.GStreamerDebugLevel, )) } if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode { validationErrors = append(validationErrors, "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true", ) } return validationErrors } func validatePrivacy(config *Config) []string { switch config.Engine.CookiePolicy { case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "": return nil default: return []string{fmt.Sprintf( "engine.cookie_policy must be one of: always, no_third_party, never (got: %s)", config.Engine.CookiePolicy, )} } } func validateColorScheme(config *Config) []string { switch config.Appearance.ColorScheme { case ThemePreferDark, ThemePreferLight, ThemeDefault, "": return nil default: return []string{fmt.Sprintf( "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", config.Appearance.ColorScheme, )} } } func validateSession(config *Config) []string { var validationErrors []string if config.Session.MaxExitedSessions < 0 { validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative") } if config.Session.MaxExitedSessionAgeDays < 0 { validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative") } if config.Session.SnapshotIntervalMs < 0 { validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative") } return validationErrors } func validatePerformanceProfile(config *Config) []string { var validationErrors []string // Performance profiles and webkit-specific tuning only apply to the webkit engine. engineType := config.Engine.ResolveEngineType() if engineType != "webkit" { return nil } // Validate profile name if !IsValidPerformanceProfile(config.Engine.Profile) { validationErrors = append(validationErrors, fmt.Sprintf( "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)", config.Engine.Profile, )) } // When profile is not "custom", warn if individual fields are set // (they will be ignored in favor of profile-computed values) if config.Engine.Profile != ProfileCustom && config.Engine.Profile != "" { perfCfg := PerformanceConfigFromEngine(&config.Engine) if HasCustomPerformanceFields(&perfCfg) { validationErrors = append(validationErrors, "engine performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+ "set profile = \"custom\" to use individual field values", ) } } // Validate memory pressure threshold ordering: WebKit requires conservative < strict webCons := config.Engine.WebKit.WebProcessMemoryConservativeThreshold webStrict := config.Engine.WebKit.WebProcessMemoryStrictThreshold if webCons > 0 && webStrict > 0 && webCons >= webStrict { validationErrors = append(validationErrors, "engine.webkit.web_process_memory_conservative_threshold must be less than "+ "web_process_memory_strict_threshold when both are set", ) } netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold if netCons > 0 && netStrict > 0 && netCons >= netStrict { validationErrors = append(validationErrors, "engine.webkit.network_process_memory_conservative_threshold must be less than "+ "network_process_memory_strict_threshold when both are set", ) } return validationErrors }