v0.30.0 eb3068f

validation.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close