v0.13.0 976176a

config.go RawHistoryFolder

1// Package config provides configuration management for dumber with Viper integration.
2package config
3
4import (
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/pkg/gpu"
14	"github.com/fsnotify/fsnotify"
15	"github.com/spf13/viper"
16)
17
18// File permission constants
19const (
20	dirPerm  = 0755 // Standard directory permissions (rwxr-xr-x)
21	filePerm = 0644 // Standard file permissions (rw-r--r--)
22)
23
24// Config represents the complete configuration for dumber.
25type Config struct {
26	Database          DatabaseConfig            `mapstructure:"database" yaml:"database"`
27	History           HistoryConfig             `mapstructure:"history" yaml:"history"`
28	SearchShortcuts   map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
29	Dmenu             DmenuConfig               `mapstructure:"dmenu" yaml:"dmenu"`
30	Logging           LoggingConfig             `mapstructure:"logging" yaml:"logging"`
31	Appearance        AppearanceConfig          `mapstructure:"appearance" yaml:"appearance"`
32	VideoAcceleration VideoAccelerationConfig   `mapstructure:"video_acceleration" yaml:"video_acceleration"`
33	CodecPreferences  CodecConfig               `mapstructure:"codec_preferences" yaml:"codec_preferences"`
34	Debug             DebugConfig               `mapstructure:"debug" yaml:"debug"`
35	// RenderingMode controls GPU/CPU rendering selection for WebKit
36	RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
37	// UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
38	UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
39	// DefaultZoom sets the default zoom level for pages without saved zoom settings (1.0 = 100%, 1.2 = 120%)
40	DefaultZoom float64 `mapstructure:"default_zoom" yaml:"default_zoom"`
41	// Workspace defines workspace, pane, and tab handling behaviour.
42	Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
43	// ContentFilteringWhitelist domains skip ad blocking (e.g. Twitch breaks with bot detection)
44	ContentFilteringWhitelist []string `mapstructure:"content_filtering_whitelist" yaml:"content_filtering_whitelist"`
45}
46
47// RenderingMode selects GPU vs CPU rendering.
48type RenderingMode string
49
50const (
51	RenderingModeAuto RenderingMode = "auto"
52	RenderingModeGPU  RenderingMode = "gpu"
53	RenderingModeCPU  RenderingMode = "cpu"
54)
55
56// DatabaseConfig holds database-related configuration.
57type DatabaseConfig struct {
58	Path string `mapstructure:"path" yaml:"path"`
59}
60
61// HistoryConfig holds history-related configuration.
62type HistoryConfig struct {
63	MaxEntries          int `mapstructure:"max_entries" yaml:"max_entries"`
64	RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
65	CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
66}
67
68// SearchShortcut represents a search shortcut configuration.
69type SearchShortcut struct {
70	URL         string `mapstructure:"url" yaml:"url" json:"url"`
71	Description string `mapstructure:"description" yaml:"description" json:"description"`
72}
73
74// DmenuConfig holds dmenu/rofi integration configuration.
75type DmenuConfig struct {
76	MaxHistoryItems  int    `mapstructure:"max_history_items" yaml:"max_history_items"`
77	ShowVisitCount   bool   `mapstructure:"show_visit_count" yaml:"show_visit_count"`
78	ShowLastVisited  bool   `mapstructure:"show_last_visited" yaml:"show_last_visited"`
79	HistoryPrefix    string `mapstructure:"history_prefix" yaml:"history_prefix"`
80	ShortcutPrefix   string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
81	URLPrefix        string `mapstructure:"url_prefix" yaml:"url_prefix"`
82	DateFormat       string `mapstructure:"date_format" yaml:"date_format"`
83	SortByVisitCount bool   `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
84}
85
86// LoggingConfig holds logging configuration.
87type LoggingConfig struct {
88	Level      string `mapstructure:"level" yaml:"level"`
89	Format     string `mapstructure:"format" yaml:"format"`
90	Filename   string `mapstructure:"filename" yaml:"filename"`
91	MaxSize    int    `mapstructure:"max_size" yaml:"max_size"`
92	MaxBackups int    `mapstructure:"max_backups" yaml:"max_backups"`
93	MaxAge     int    `mapstructure:"max_age" yaml:"max_age"`
94	Compress   bool   `mapstructure:"compress" yaml:"compress"`
95
96	// File output configuration
97	LogDir        string `mapstructure:"log_dir" yaml:"log_dir"`
98	EnableFileLog bool   `mapstructure:"enable_file_log" yaml:"enable_file_log"`
99
100	// Capture settings
101	CaptureStdout  bool `mapstructure:"capture_stdout" yaml:"capture_stdout" json:"capture_stdout"`
102	CaptureStderr  bool `mapstructure:"capture_stderr" yaml:"capture_stderr" json:"capture_stderr"`
103	CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output" json:"capture_c_output"`
104	CaptureConsole bool `mapstructure:"capture_console" yaml:"capture_console" json:"capture_console"`
105
106	// Debug output
107	DebugFile     string `mapstructure:"debug_file" yaml:"debug_file"`
108	VerboseWebKit bool   `mapstructure:"verbose_webkit" yaml:"verbose_webkit"`
109}
110
111// AppearanceConfig holds UI/rendering preferences.
112type AppearanceConfig struct {
113	// Default fonts for pages that do not specify fonts.
114	SansFont      string `mapstructure:"sans_font" yaml:"sans_font"`
115	SerifFont     string `mapstructure:"serif_font" yaml:"serif_font"`
116	MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
117	// Default font size in CSS pixels (approx).
118	DefaultFontSize int          `mapstructure:"default_font_size" yaml:"default_font_size"`
119	LightPalette    ColorPalette `mapstructure:"light_palette" yaml:"light_palette"`
120	DarkPalette     ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette"`
121	// ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
122	ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme"`
123}
124
125// ColorPalette contains semantic color tokens for light/dark themes.
126type ColorPalette struct {
127	Background     string `mapstructure:"background" yaml:"background" json:"background"`
128	Surface        string `mapstructure:"surface" yaml:"surface" json:"surface"`
129	SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" json:"surface_variant"`
130	Text           string `mapstructure:"text" yaml:"text" json:"text"`
131	Muted          string `mapstructure:"muted" yaml:"muted" json:"muted"`
132	Accent         string `mapstructure:"accent" yaml:"accent" json:"accent"`
133	Border         string `mapstructure:"border" yaml:"border" json:"border"`
134}
135
136// VideoAccelerationConfig holds video hardware acceleration preferences.
137type VideoAccelerationConfig struct {
138	EnableVAAPI      bool   `mapstructure:"enable_vaapi" yaml:"enable_vaapi"`
139	AutoDetectGPU    bool   `mapstructure:"auto_detect_gpu" yaml:"auto_detect_gpu"`
140	VAAPIDriverName  string `mapstructure:"vaapi_driver_name" yaml:"vaapi_driver_name"`
141	EnableAllDrivers bool   `mapstructure:"enable_all_drivers" yaml:"enable_all_drivers"`
142	LegacyVAAPI      bool   `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`
143}
144
145// CodecConfig holds video codec preferences and handling
146type CodecConfig struct {
147	// Codec preference order (e.g., "av1,h264,vp8")
148	PreferredCodecs string `mapstructure:"preferred_codecs" yaml:"preferred_codecs"`
149
150	// Force specific codec for platforms
151	ForceAV1 bool `mapstructure:"force_av1" yaml:"force_av1"`
152
153	// Block problematic codecs
154	BlockVP9 bool `mapstructure:"block_vp9" yaml:"block_vp9"`
155	BlockVP8 bool `mapstructure:"block_vp8" yaml:"block_vp8"`
156
157	// Hardware acceleration per codec
158	AV1HardwareOnly    bool `mapstructure:"av1_hardware_only" yaml:"av1_hardware_only"`
159	DisableVP9Hardware bool `mapstructure:"disable_vp9_hardware" yaml:"disable_vp9_hardware"`
160
161	// Buffer configuration for smooth playback
162	VideoBufferSizeMB  int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb"`
163	QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec"`
164
165	// Custom User-Agent for codec negotiation
166	CustomUserAgent string `mapstructure:"custom_user_agent" yaml:"custom_user_agent"`
167
168	// Maximum resolution for AV1 codec (720p, 1080p, 1440p, 4k, unlimited)
169	AV1MaxResolution string `mapstructure:"av1_max_resolution" yaml:"av1_max_resolution"`
170
171	// Site-specific codec control settings
172	DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"`
173}
174
175// DebugConfig holds debug and troubleshooting options
176type DebugConfig struct {
177	// Enable WebKit internal debug logging
178	EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"`
179
180	// WebKit debug categories (comma-separated)
181	// Common values: "Network:preconnectTo", "ContentFilters", "Loading", "JavaScript"
182	WebKitDebugCategories string `mapstructure:"webkit_debug_categories" yaml:"webkit_debug_categories"`
183
184	// Enable content filtering debug logs
185	EnableFilteringDebug bool `mapstructure:"enable_filtering_debug" yaml:"enable_filtering_debug"`
186
187	// Enable detailed WebView state logging
188	EnableWebViewDebug bool `mapstructure:"enable_webview_debug" yaml:"enable_webview_debug"`
189
190	// Log WebKit crashes and errors to file
191	LogWebKitCrashes bool `mapstructure:"log_webkit_crashes" yaml:"log_webkit_crashes"`
192
193	// Enable script injection debug logs
194	EnableScriptDebug bool `mapstructure:"enable_script_debug" yaml:"enable_script_debug"`
195
196	// Enable general debug mode (equivalent to DUMBER_DEBUG env var)
197	EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`
198
199	// Enable workspace navigation and focus debug logs
200	EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
201
202	// Enable focus state machine debug logs
203	EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug"`
204
205	// Enable CSS reconciler debug logs
206	EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug"`
207
208	// Enable focus metrics tracking
209	EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics"`
210
211	// Enable detailed pane close instrumentation and tree snapshots
212	EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`
213}
214
215// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
216type WorkspaceConfig struct {
217	// EnableZellijControls toggles Zellij-inspired keybindings.
218	EnableZellijControls bool `mapstructure:"enable_zellij_controls" yaml:"enable_zellij_controls" json:"enable_zellij_controls"`
219	// PaneMode defines modal pane behaviour and bindings.
220	PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" json:"pane_mode"`
221	// Tabs holds classic browser tab shortcuts.
222	Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" json:"tabs"`
223	// Popups configures default popup placement rules.
224	Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" json:"popups"`
225	// Styling configures workspace visual appearance.
226	Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" json:"styling"`
227}
228
229// PaneModeConfig defines modal behaviour for pane management.
230type PaneModeConfig struct {
231	ActivationShortcut  string            `mapstructure:"activation_shortcut" yaml:"activation_shortcut" json:"activation_shortcut"`
232	TimeoutMilliseconds int               `mapstructure:"timeout_ms" yaml:"timeout_ms" json:"timeout_ms"`
233	ActionBindings      map[string]string `mapstructure:"action_bindings" yaml:"action_bindings" json:"action_bindings"`
234}
235
236// TabKeyConfig defines Zellij-inspired tab shortcuts.
237type TabKeyConfig struct {
238	NewTab      string `mapstructure:"new_tab" yaml:"new_tab" json:"new_tab"`
239	CloseTab    string `mapstructure:"close_tab" yaml:"close_tab" json:"close_tab"`
240	NextTab     string `mapstructure:"next_tab" yaml:"next_tab" json:"next_tab"`
241	PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`
242}
243
244// PopupBehavior defines how popup windows should be opened
245type PopupBehavior string
246
247const (
248	// PopupBehaviorSplit opens popups in a split pane (default)
249	PopupBehaviorSplit PopupBehavior = "split"
250	// PopupBehaviorStacked opens popups in a stacked pane
251	PopupBehaviorStacked PopupBehavior = "stacked"
252	// PopupBehaviorTabbed opens popups as a new tab
253	PopupBehaviorTabbed PopupBehavior = "tabbed"
254	// PopupBehaviorWindowed opens popups in a new workspace window
255	PopupBehaviorWindowed PopupBehavior = "windowed"
256)
257
258// PopupBehaviorConfig defines handling for popup windows.
259type PopupBehaviorConfig struct {
260	// Behavior determines how popups are opened (split/stacked/tabbed/windowed)
261	Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" json:"behavior"`
262
263	// Placement specifies direction for split behavior ("right", "left", "top", "bottom")
264	// Only used when Behavior is "split"
265	Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
266
267	// OpenInNewPane controls whether popups are opened in workspace or blocked
268	OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
269
270	// FollowPaneContext determines if popup placement follows parent pane context
271	FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
272
273	// BlankTargetBehavior determines how to handle window.open(url, "_blank") intents
274	// Accepted values: "pane" (default) or "tab" (future support)
275	BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
276
277	// EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
278	EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"`
279
280	// OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
281	OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" json:"oauth_auto_close"`
282}
283
284// WorkspaceStylingConfig defines visual styling for workspace panes.
285type WorkspaceStylingConfig struct {
286	// BorderWidth in pixels for pane borders
287	BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`
288	// BorderColor for focused panes (CSS color value or theme variable)
289	BorderColor string `mapstructure:"border_color" yaml:"border_color" json:"border_color"`
290	// PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
291	// Defaults to "#FFA500" (orange) if not set
292	PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" json:"pane_mode_border_color"`
293	// TransitionDuration in milliseconds for border animations
294	TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`
295	// BorderRadius in pixels for pane border corners
296	BorderRadius int `mapstructure:"border_radius" yaml:"border_radius" json:"border_radius"`
297}
298
299// Manager handles configuration loading, watching, and reloading.
300type Manager struct {
301	config    *Config
302	viper     *viper.Viper
303	mu        sync.RWMutex
304	callbacks []func(*Config)
305	watching  bool
306}
307
308// NewManager creates a new configuration manager.
309func NewManager() (*Manager, error) {
310	v := viper.New()
311
312	// Configure Viper - supports yaml, json, toml automatically
313	v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
314
315	// Add config paths
316	configDir, err := GetConfigDir()
317	if err != nil {
318		return nil, fmt.Errorf("failed to get config directory: %w", err)
319	}
320	v.AddConfigPath(configDir)
321	v.AddConfigPath(".") // Current directory for development
322
323	// Set up environment variable support
324	v.SetEnvPrefix("DUMB_BROWSER")
325	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
326	v.AutomaticEnv()
327
328	// Bind specific environment variables
329	bindings := map[string]string{
330		"database.path":             "DATABASE_PATH",
331		"history.max_entries":       "HISTORY_MAX_ENTRIES",
332		"history.retention_period":  "HISTORY_RETENTION_PERIOD",
333		"history.cleanup_interval":  "HISTORY_CLEANUP_INTERVAL",
334		"dmenu.max_history_items":   "DMENU_MAX_HISTORY_ITEMS",
335		"dmenu.show_visit_count":    "DMENU_SHOW_VISIT_COUNT",
336		"dmenu.show_last_visited":   "DMENU_SHOW_LAST_VISITED",
337		"dmenu.history_prefix":      "DMENU_HISTORY_PREFIX",
338		"dmenu.shortcut_prefix":     "DMENU_SHORTCUT_PREFIX",
339		"dmenu.url_prefix":          "DMENU_URL_PREFIX",
340		"dmenu.date_format":         "DMENU_DATE_FORMAT",
341		"dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
342		"logging.level":             "LOGGING_LEVEL",
343		"logging.format":            "LOGGING_FORMAT",
344		"logging.filename":          "LOGGING_FILENAME",
345		"logging.max_size":          "LOGGING_MAX_SIZE",
346		"logging.max_backups":       "LOGGING_MAX_BACKUPS",
347		"logging.max_age":           "LOGGING_MAX_AGE",
348		"logging.compress":          "LOGGING_COMPRESS",
349	}
350
351	for key, env := range bindings {
352		if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
353			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
354		}
355	}
356
357	// Explicit binding for rendering mode via dedicated env var
358	if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
359		return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
360	}
361	if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
362		return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
363	}
364	if err := v.BindEnv("default_zoom", "DUMBER_DEFAULT_ZOOM"); err != nil {
365		return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_ZOOM: %w", err)
366	}
367
368	// Video acceleration environment variable bindings
369	videoAccelEnvBindings := map[string]string{
370		"video_acceleration.enable_vaapi":       "DUMBER_VIDEO_ACCELERATION_ENABLE",
371		"video_acceleration.auto_detect_gpu":    "DUMBER_VIDEO_AUTO_DETECT",
372		"video_acceleration.vaapi_driver_name":  "LIBVA_DRIVER_NAME",
373		"video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
374		"video_acceleration.legacy_vaapi":       "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
375	}
376
377	for key, env := range videoAccelEnvBindings {
378		if err := v.BindEnv(key, env); err != nil {
379			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
380		}
381	}
382
383	// Codec preferences environment variable bindings
384	codecEnvBindings := map[string]string{
385		"codec_preferences.preferred_codecs":             "DUMBER_PREFERRED_CODECS",
386		"codec_preferences.force_av1":                    "DUMBER_FORCE_AV1",
387		"codec_preferences.block_vp9":                    "DUMBER_BLOCK_VP9",
388		"codec_preferences.block_vp8":                    "DUMBER_BLOCK_VP8",
389		"codec_preferences.av1_hardware_only":            "DUMBER_AV1_HW_ONLY",
390		"codec_preferences.disable_vp9_hardware":         "DUMBER_DISABLE_VP9_HW",
391		"codec_preferences.video_buffer_size_mb":         "DUMBER_VIDEO_BUFFER_MB",
392		"codec_preferences.queue_buffer_time_sec":        "DUMBER_QUEUE_BUFFER_SEC",
393		"codec_preferences.custom_user_agent":            "DUMBER_CUSTOM_UA",
394		"codec_preferences.av1_max_resolution":           "DUMBER_AV1_MAX_RES",
395		"codec_preferences.disable_twitch_codec_control": "DUMBER_DISABLE_TWITCH_CODEC",
396	}
397
398	for key, env := range codecEnvBindings {
399		if err := v.BindEnv(key, env); err != nil {
400			return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
401		}
402	}
403
404	return &Manager{
405		viper:     v,
406		callbacks: make([]func(*Config), 0),
407	}, nil
408}
409
410// Load loads the configuration from file and environment variables.
411func (m *Manager) Load() error {
412	m.mu.Lock()
413	defer m.mu.Unlock()
414
415	// Ensure directories exist
416	if err := EnsureDirectories(); err != nil {
417		return fmt.Errorf("failed to ensure directories: %w", err)
418	}
419
420	// Set defaults
421	m.setDefaults()
422
423	// Read config file if it exists
424	if err := m.viper.ReadInConfig(); err != nil {
425		var configFileNotFoundError viper.ConfigFileNotFoundError
426		if errors.As(err, &configFileNotFoundError) {
427			// Config file not found, create default one
428			if err := m.createDefaultConfig(); err != nil {
429				return fmt.Errorf("failed to create default config: %w", err)
430			}
431			// Re-read the newly created config file
432			if err := m.viper.ReadInConfig(); err != nil {
433				return fmt.Errorf("failed to read newly created config: %w", err)
434			}
435		} else {
436			return fmt.Errorf("failed to read config file: %w", err)
437		}
438	}
439
440	// Unmarshal into config struct
441	config := &Config{}
442	if err := m.viper.Unmarshal(config); err != nil {
443		return fmt.Errorf("failed to unmarshal config: %w", err)
444	}
445
446	// Set database path if not specified
447	if config.Database.Path == "" {
448		dbPath, err := GetDatabaseFile()
449		if err != nil {
450			return fmt.Errorf("failed to get database path: %w", err)
451		}
452		config.Database.Path = dbPath
453	}
454
455	// Normalize/validate rendering mode
456	switch strings.ToLower(string(config.RenderingMode)) {
457	case "", string(RenderingModeAuto):
458		config.RenderingMode = RenderingModeAuto
459	case string(RenderingModeGPU):
460		config.RenderingMode = RenderingModeGPU
461	case string(RenderingModeCPU):
462		config.RenderingMode = RenderingModeCPU
463	default:
464		config.RenderingMode = RenderingModeAuto
465	}
466
467	// Auto-detect GPU if enabled and driver name is not set
468	if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
469		gpuInfo := gpu.DetectGPU()
470		if gpuInfo.SupportsVAAPI() {
471			config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
472		}
473	}
474
475	// Validate and configure codec preferences
476	config = m.validateAndConfigureCodecPreferences(config)
477
478	// Validate ColorScheme setting
479	m.validateColorScheme(config)
480
481	m.config = config
482	return nil
483}
484
485// Get returns the current configuration (thread-safe).
486func (m *Manager) Get() *Config {
487	m.mu.RLock()
488	defer m.mu.RUnlock()
489
490	// Return a copy to prevent external modification
491	configCopy := *m.config
492	return &configCopy
493}
494
495// Watch starts watching the config file for changes and reloads automatically.
496func (m *Manager) Watch() error {
497	m.mu.Lock()
498	defer m.mu.Unlock()
499
500	if m.watching {
501		return nil // Already watching
502	}
503
504	m.viper.WatchConfig()
505	m.viper.OnConfigChange(func(_ fsnotify.Event) {
506		// Reload config
507		if err := m.reload(); err != nil {
508			fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
509			return
510		}
511
512		// Notify callbacks
513		m.mu.RLock()
514		config := m.config
515		callbacks := make([]func(*Config), len(m.callbacks))
516		copy(callbacks, m.callbacks)
517		m.mu.RUnlock()
518
519		for _, callback := range callbacks {
520			callback(config)
521		}
522	})
523
524	m.watching = true
525	return nil
526}
527
528// OnConfigChange registers a callback function to be called when config changes.
529func (m *Manager) OnConfigChange(callback func(*Config)) {
530	m.mu.Lock()
531	defer m.mu.Unlock()
532
533	m.callbacks = append(m.callbacks, callback)
534}
535
536// reload reloads the configuration (internal method, must be called with lock).
537func (m *Manager) reload() error {
538	if err := m.viper.ReadInConfig(); err != nil {
539		return err
540	}
541
542	config := &Config{}
543	if err := m.viper.Unmarshal(config); err != nil {
544		return err
545	}
546
547	// Set database path if not specified
548	if config.Database.Path == "" {
549		dbPath, err := GetDatabaseFile()
550		if err != nil {
551			return fmt.Errorf("failed to get database path: %w", err)
552		}
553		config.Database.Path = dbPath
554	}
555
556	// Normalize/validate rendering mode
557	switch strings.ToLower(string(config.RenderingMode)) {
558	case "", string(RenderingModeAuto):
559		config.RenderingMode = RenderingModeAuto
560	case string(RenderingModeGPU):
561		config.RenderingMode = RenderingModeGPU
562	case string(RenderingModeCPU):
563		config.RenderingMode = RenderingModeCPU
564	default:
565		config.RenderingMode = RenderingModeAuto
566	}
567
568	// Auto-detect GPU if enabled and driver name is not set
569	if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
570		gpuInfo := gpu.DetectGPU()
571		if gpuInfo.SupportsVAAPI() {
572			config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
573		}
574	}
575
576	// Validate and configure codec preferences
577	config = m.validateAndConfigureCodecPreferences(config)
578
579	// Validate ColorScheme setting
580	m.validateColorScheme(config)
581
582	m.config = config
583	return nil
584}
585
586// setDefaults sets default configuration values in Viper.
587func (m *Manager) setDefaults() {
588	defaults := DefaultConfig()
589
590	// Note: Database.Path is set dynamically in Load(), no defaults needed
591
592	// Note: Browser config removed - we build our own browser
593
594	// History defaults
595	m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
596	m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
597	m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
598
599	// Search shortcuts defaults
600	m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
601
602	// Dmenu defaults
603	m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
604	m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
605	m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
606	m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
607	m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
608	m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
609	m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
610	m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
611
612	// Logging defaults
613	m.viper.SetDefault("logging.level", defaults.Logging.Level)
614	m.viper.SetDefault("logging.format", defaults.Logging.Format)
615	m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
616	m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
617	m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
618	m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
619	m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
620	m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
621	m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
622	m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout)
623	m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr)
624	m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput)
625	m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
626	m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
627
628	// Debug defaults
629	m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
630	m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
631	m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
632	m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
633	m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
634	m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
635	m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
636	m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
637	m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
638	m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
639	m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
640	m.viper.SetDefault("debug.enable_pane_close_debug", defaults.Debug.EnablePaneCloseDebug)
641
642	// Appearance defaults
643	m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
644	m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
645	m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
646	m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
647	m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
648	m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
649	m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
650
651	// Video acceleration defaults
652	m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
653	m.viper.SetDefault("video_acceleration.auto_detect_gpu", defaults.VideoAcceleration.AutoDetectGPU)
654	m.viper.SetDefault("video_acceleration.vaapi_driver_name", defaults.VideoAcceleration.VAAPIDriverName)
655	m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
656	m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
657
658	// Codec preferences defaults
659	m.viper.SetDefault("codec_preferences.preferred_codecs", defaults.CodecPreferences.PreferredCodecs)
660	m.viper.SetDefault("codec_preferences.force_av1", defaults.CodecPreferences.ForceAV1)
661	m.viper.SetDefault("codec_preferences.block_vp9", defaults.CodecPreferences.BlockVP9)
662	m.viper.SetDefault("codec_preferences.block_vp8", defaults.CodecPreferences.BlockVP8)
663	m.viper.SetDefault("codec_preferences.av1_hardware_only", defaults.CodecPreferences.AV1HardwareOnly)
664	m.viper.SetDefault("codec_preferences.disable_vp9_hardware", defaults.CodecPreferences.DisableVP9Hardware)
665	m.viper.SetDefault("codec_preferences.video_buffer_size_mb", defaults.CodecPreferences.VideoBufferSizeMB)
666	m.viper.SetDefault("codec_preferences.queue_buffer_time_sec", defaults.CodecPreferences.QueueBufferTimeSec)
667	m.viper.SetDefault("codec_preferences.custom_user_agent", defaults.CodecPreferences.CustomUserAgent)
668	m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
669	m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
670
671	// Rendering defaults
672	m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
673	m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
674	m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
675
676	// Workspace defaults
677	m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
678	m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
679	m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
680	m.viper.SetDefault("workspace.pane_mode.action_bindings", defaults.Workspace.PaneMode.ActionBindings)
681	m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
682	m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
683	m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
684	m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
685	m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
686	m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
687	m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
688	m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
689	// New popup behaviour defaults
690	m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
691	m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
692	m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
693	m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
694	m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
695	m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
696	m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
697	m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)
698}
699
700// createDefaultConfig creates a default configuration file.
701func (m *Manager) createDefaultConfig() error {
702	configFile, err := GetConfigFile()
703	if err != nil {
704		return err
705	}
706
707	// Create config directory if it doesn't exist
708	if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
709		return err
710	}
711
712	// Use Viper's SafeWriteConfigAs to avoid duplicate keys
713	if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
714		return fmt.Errorf("failed to write config file: %w", err)
715	}
716
717	fmt.Printf("Created default configuration file: %s\n", configFile)
718
719	// Generate JSON schema file
720	if err := GenerateSchemaFile(); err != nil {
721		// Log error but don't fail config creation
722		fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err)
723	}
724
725	return nil
726}
727
728// GetConfigFile returns the path to the configuration file being used.
729func (m *Manager) GetConfigFile() string {
730	return m.viper.ConfigFileUsed()
731}
732
733// New returns a new default configuration instance.
734// This is a convenience function for getting default config without the full manager.
735func New() *Config {
736	return DefaultConfig()
737}
738
739// Global configuration manager instance
740var globalManager *Manager
741var globalManagerOnce sync.Once
742
743// Init initializes the global configuration manager.
744func Init() error {
745	var err error
746	globalManagerOnce.Do(func() {
747		globalManager, err = NewManager()
748		if err != nil {
749			return
750		}
751		err = globalManager.Load()
752	})
753	return err
754}
755
756// Get returns the global configuration.
757func Get() *Config {
758	if globalManager == nil {
759		// Return defaults if not initialized
760		return DefaultConfig()
761	}
762	return globalManager.Get()
763}
764
765// Watch starts watching the global configuration for changes.
766func Watch() error {
767	if globalManager == nil {
768		return fmt.Errorf("configuration not initialized")
769	}
770	return globalManager.Watch()
771}
772
773// OnConfigChange registers a callback for global configuration changes.
774func OnConfigChange(callback func(*Config)) {
775	if globalManager == nil {
776		return
777	}
778	globalManager.OnConfigChange(callback)
779}
780
781// validateAndConfigureCodecPreferences validates and auto-configures codec preferences based on GPU capabilities
782func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
783	// Validate codec preferences
784	if config.CodecPreferences.PreferredCodecs == "" {
785		config.CodecPreferences.PreferredCodecs = "av1,h264,vp8"
786		fmt.Printf("Config: Set default codec preferences: %s\n", config.CodecPreferences.PreferredCodecs)
787	}
788
789	// Auto-configure based on GPU capabilities
790	if config.VideoAcceleration.AutoDetectGPU {
791		gpuInfo := gpu.DetectGPU()
792
793		// Check AV1 hardware support
794		if config.CodecPreferences.AV1HardwareOnly && !gpuInfo.SupportsAV1Hardware() {
795			// Disable AV1 hardware-only if GPU doesn't support it
796			config.CodecPreferences.AV1HardwareOnly = false
797			fmt.Printf("Config: GPU doesn't support AV1 hardware, enabling software fallback\n")
798		}
799
800		// Get detailed AV1 capabilities
801		av1Caps := gpuInfo.GetAV1HardwareCapabilities()
802		if av1Caps["decode"] {
803			logging.Info("GPU supports AV1 hardware decode")
804
805			// If GPU supports AV1, prefer it over VP9
806			if !config.CodecPreferences.ForceAV1 &&
807				!strings.HasPrefix(config.CodecPreferences.PreferredCodecs, "av1") {
808				config.CodecPreferences.PreferredCodecs = "av1," + config.CodecPreferences.PreferredCodecs
809				fmt.Printf("Config: Auto-enabled AV1 preference due to hardware support\n")
810			}
811		} else {
812			// If no AV1 support, ensure VP9 hardware is disabled to prevent issues
813			if !config.CodecPreferences.DisableVP9Hardware {
814				config.CodecPreferences.DisableVP9Hardware = true
815				fmt.Printf("Config: Auto-disabled VP9 hardware acceleration (no AV1 fallback)\n")
816			}
817		}
818
819		// Log GPU-specific codec capabilities
820		logging.Info(fmt.Sprintf("%s GPU codec capabilities - AV1: decode=%t, encode=%t, 10bit=%t",
821			gpuInfo.Vendor, av1Caps["decode"], av1Caps["encode"], av1Caps["10bit"]))
822	}
823
824	return config
825}
826
827// validateColorScheme validates and normalizes the ColorScheme setting
828func (m *Manager) validateColorScheme(config *Config) {
829	switch config.Appearance.ColorScheme {
830	case "prefer-dark", "prefer-light", ThemeDefault, "":
831		// Valid values - no changes needed
832		// Empty string is treated the same as "default"
833		if config.Appearance.ColorScheme == "" {
834			config.Appearance.ColorScheme = ThemeDefault
835			logging.Info("Config: ColorScheme not set, defaulting to '" + ThemeDefault + "' (follows system)")
836		}
837	default:
838		// Invalid value - warn and reset to default
839		logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', '"+ThemeDefault+"'. Resetting to '"+ThemeDefault+"'",
840			config.Appearance.ColorScheme))
841		config.Appearance.ColorScheme = ThemeDefault
842	}
843}

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