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