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