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/pkg/gpu"
15 "github.com/fsnotify/fsnotify"
16 "github.com/spf13/viper"
17)
18
19// File permission constants
20const (
21 dirPerm = 0755 // Standard directory permissions (rwxr-xr-x)
22 filePerm = 0644 // Standard file permissions (rw-r--r--)
23)
24
25// Config represents the complete configuration for dumber.
26type Config struct {
27 Database DatabaseConfig `mapstructure:"database" yaml:"database"`
28 History HistoryConfig `mapstructure:"history" yaml:"history"`
29 SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
30 Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
31 Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
32 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
33 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
34 // RenderingMode controls GPU/CPU rendering selection for WebKit
35 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
36}
37
38// RenderingMode selects GPU vs CPU rendering.
39type RenderingMode string
40
41const (
42 RenderingModeAuto RenderingMode = "auto"
43 RenderingModeGPU RenderingMode = "gpu"
44 RenderingModeCPU RenderingMode = "cpu"
45)
46
47// DatabaseConfig holds database-related configuration.
48type DatabaseConfig struct {
49 Path string `mapstructure:"path" yaml:"path"`
50 MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"`
51 MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
52 QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
53}
54
55// HistoryConfig holds history-related configuration.
56type HistoryConfig struct {
57 MaxEntries int `mapstructure:"max_entries" yaml:"max_entries"`
58 RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
59 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
60}
61
62// SearchShortcut represents a search shortcut configuration.
63type SearchShortcut struct {
64 URL string `mapstructure:"url" yaml:"url" json:"url"`
65 Description string `mapstructure:"description" yaml:"description" json:"description"`
66}
67
68// DmenuConfig holds dmenu/rofi integration configuration.
69type DmenuConfig struct {
70 MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items"`
71 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count"`
72 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited"`
73 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix"`
74 ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
75 URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix"`
76 DateFormat string `mapstructure:"date_format" yaml:"date_format"`
77 SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
78}
79
80// LoggingConfig holds logging configuration.
81type LoggingConfig struct {
82 Level string `mapstructure:"level" yaml:"level"`
83 Format string `mapstructure:"format" yaml:"format"`
84 Filename string `mapstructure:"filename" yaml:"filename"`
85 MaxSize int `mapstructure:"max_size" yaml:"max_size"`
86 MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
87 MaxAge int `mapstructure:"max_age" yaml:"max_age"`
88 Compress bool `mapstructure:"compress" yaml:"compress"`
89
90 // File output configuration
91 LogDir string `mapstructure:"log_dir" yaml:"log_dir"`
92 EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log"`
93
94 // Capture settings
95 CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout"`
96 CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr"`
97 CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output"`
98
99 // Debug output
100 DebugFile string `mapstructure:"debug_file" yaml:"debug_file"`
101 VerboseWebKit bool `mapstructure:"verbose_webkit" yaml:"verbose_webkit"`
102}
103
104// AppearanceConfig holds UI/rendering preferences.
105type AppearanceConfig struct {
106 // Default fonts for pages that do not specify fonts.
107 SansFont string `mapstructure:"sans_font" yaml:"sans_font"`
108 SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
109 MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
110 // Default font size in CSS pixels (approx).
111 DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
112}
113
114// VideoAccelerationConfig holds video hardware acceleration preferences.
115type VideoAccelerationConfig struct {
116 EnableVAAPI bool `mapstructure:"enable_vaapi" yaml:"enable_vaapi"`
117 AutoDetectGPU bool `mapstructure:"auto_detect_gpu" yaml:"auto_detect_gpu"`
118 VAAPIDriverName string `mapstructure:"vaapi_driver_name" yaml:"vaapi_driver_name"`
119 EnableAllDrivers bool `mapstructure:"enable_all_drivers" yaml:"enable_all_drivers"`
120 LegacyVAAPI bool `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi"`
121}
122
123// Manager handles configuration loading, watching, and reloading.
124type Manager struct {
125 config *Config
126 viper *viper.Viper
127 mu sync.RWMutex
128 callbacks []func(*Config)
129 watching bool
130}
131
132// NewManager creates a new configuration manager.
133func NewManager() (*Manager, error) {
134 v := viper.New()
135
136 // Configure Viper - supports yaml, json, toml automatically
137 v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
138
139 // Add config paths
140 configDir, err := GetConfigDir()
141 if err != nil {
142 return nil, fmt.Errorf("failed to get config directory: %w", err)
143 }
144 v.AddConfigPath(configDir)
145 v.AddConfigPath(".") // Current directory for development
146
147 // Set up environment variable support
148 v.SetEnvPrefix("DUMB_BROWSER")
149 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
150 v.AutomaticEnv()
151
152 // Bind specific environment variables
153 bindings := map[string]string{
154 "database.path": "DATABASE_PATH",
155 "database.max_connections": "DATABASE_MAX_CONNECTIONS",
156 "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
157 "database.query_timeout": "DATABASE_QUERY_TIMEOUT",
158 "browser.command": "BROWSER_COMMAND",
159 "browser.timeout": "BROWSER_TIMEOUT",
160 "browser.detach_process": "BROWSER_DETACH_PROCESS",
161 "history.max_entries": "HISTORY_MAX_ENTRIES",
162 "history.retention_period": "HISTORY_RETENTION_PERIOD",
163 "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
164 "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
165 "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
166 "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
167 "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
168 "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
169 "dmenu.url_prefix": "DMENU_URL_PREFIX",
170 "dmenu.date_format": "DMENU_DATE_FORMAT",
171 "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
172 "logging.level": "LOGGING_LEVEL",
173 "logging.format": "LOGGING_FORMAT",
174 "logging.filename": "LOGGING_FILENAME",
175 "logging.max_size": "LOGGING_MAX_SIZE",
176 "logging.max_backups": "LOGGING_MAX_BACKUPS",
177 "logging.max_age": "LOGGING_MAX_AGE",
178 "logging.compress": "LOGGING_COMPRESS",
179 }
180
181 for key, env := range bindings {
182 if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
183 return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
184 }
185 }
186
187 // Explicit binding for rendering mode via dedicated env var
188 if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
189 return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
190 }
191
192 // Video acceleration environment variable bindings
193 videoAccelEnvBindings := map[string]string{
194 "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
195 "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
196 "video_acceleration.vaapi_driver_name": "LIBVA_DRIVER_NAME",
197 "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
198 "video_acceleration.legacy_vaapi": "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
199 }
200
201 for key, env := range videoAccelEnvBindings {
202 if err := v.BindEnv(key, env); err != nil {
203 return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
204 }
205 }
206
207 return &Manager{
208 viper: v,
209 callbacks: make([]func(*Config), 0),
210 }, nil
211}
212
213// Load loads the configuration from file and environment variables.
214func (m *Manager) Load() error {
215 m.mu.Lock()
216 defer m.mu.Unlock()
217
218 // Ensure directories exist
219 if err := EnsureDirectories(); err != nil {
220 return fmt.Errorf("failed to ensure directories: %w", err)
221 }
222
223 // Set defaults
224 m.setDefaults()
225
226 // Read config file if it exists
227 if err := m.viper.ReadInConfig(); err != nil {
228 var configFileNotFoundError viper.ConfigFileNotFoundError
229 if errors.As(err, &configFileNotFoundError) {
230 // Config file not found, create default one
231 if err := m.createDefaultConfig(); err != nil {
232 return fmt.Errorf("failed to create default config: %w", err)
233 }
234 } else {
235 return fmt.Errorf("failed to read config file: %w", err)
236 }
237 }
238
239 // Unmarshal into config struct
240 config := &Config{}
241 if err := m.viper.Unmarshal(config); err != nil {
242 return fmt.Errorf("failed to unmarshal config: %w", err)
243 }
244
245 // Set database path if not specified
246 if config.Database.Path == "" {
247 dbPath, err := GetDatabaseFile()
248 if err != nil {
249 return fmt.Errorf("failed to get database path: %w", err)
250 }
251 config.Database.Path = dbPath
252 }
253
254 // Normalize/validate rendering mode
255 switch strings.ToLower(string(config.RenderingMode)) {
256 case "", string(RenderingModeAuto):
257 config.RenderingMode = RenderingModeAuto
258 case string(RenderingModeGPU):
259 config.RenderingMode = RenderingModeGPU
260 case string(RenderingModeCPU):
261 config.RenderingMode = RenderingModeCPU
262 default:
263 config.RenderingMode = RenderingModeAuto
264 }
265
266 // Auto-detect GPU if enabled and driver name is not set
267 if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
268 gpuInfo := gpu.DetectGPU()
269 if gpuInfo.SupportsVAAPI() {
270 config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
271 }
272 }
273
274 m.config = config
275 return nil
276}
277
278// Get returns the current configuration (thread-safe).
279func (m *Manager) Get() *Config {
280 m.mu.RLock()
281 defer m.mu.RUnlock()
282
283 // Return a copy to prevent external modification
284 configCopy := *m.config
285 return &configCopy
286}
287
288// Watch starts watching the config file for changes and reloads automatically.
289func (m *Manager) Watch() error {
290 m.mu.Lock()
291 defer m.mu.Unlock()
292
293 if m.watching {
294 return nil // Already watching
295 }
296
297 m.viper.WatchConfig()
298 m.viper.OnConfigChange(func(_ fsnotify.Event) {
299 // Reload config
300 if err := m.reload(); err != nil {
301 fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
302 return
303 }
304
305 // Notify callbacks
306 m.mu.RLock()
307 config := m.config
308 callbacks := make([]func(*Config), len(m.callbacks))
309 copy(callbacks, m.callbacks)
310 m.mu.RUnlock()
311
312 for _, callback := range callbacks {
313 callback(config)
314 }
315 })
316
317 m.watching = true
318 return nil
319}
320
321// OnConfigChange registers a callback function to be called when config changes.
322func (m *Manager) OnConfigChange(callback func(*Config)) {
323 m.mu.Lock()
324 defer m.mu.Unlock()
325
326 m.callbacks = append(m.callbacks, callback)
327}
328
329// reload reloads the configuration (internal method, must be called with lock).
330func (m *Manager) reload() error {
331 if err := m.viper.ReadInConfig(); err != nil {
332 return err
333 }
334
335 config := &Config{}
336 if err := m.viper.Unmarshal(config); err != nil {
337 return err
338 }
339
340 // Set database path if not specified
341 if config.Database.Path == "" {
342 dbPath, err := GetDatabaseFile()
343 if err != nil {
344 return fmt.Errorf("failed to get database path: %w", err)
345 }
346 config.Database.Path = dbPath
347 }
348
349 // Normalize/validate rendering mode
350 switch strings.ToLower(string(config.RenderingMode)) {
351 case "", string(RenderingModeAuto):
352 config.RenderingMode = RenderingModeAuto
353 case string(RenderingModeGPU):
354 config.RenderingMode = RenderingModeGPU
355 case string(RenderingModeCPU):
356 config.RenderingMode = RenderingModeCPU
357 default:
358 config.RenderingMode = RenderingModeAuto
359 }
360
361 // Auto-detect GPU if enabled and driver name is not set
362 if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
363 gpuInfo := gpu.DetectGPU()
364 if gpuInfo.SupportsVAAPI() {
365 config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
366 }
367 }
368
369 m.config = config
370 return nil
371}
372
373// setDefaults sets default configuration values in Viper.
374func (m *Manager) setDefaults() {
375 defaults := DefaultConfig()
376
377 // Database defaults
378 m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
379 m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
380 m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
381
382 // Note: Browser config removed - we build our own browser
383
384 // History defaults
385 m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
386 m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
387 m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
388
389 // Search shortcuts defaults
390 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
391
392 // Dmenu defaults
393 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
394 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
395 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
396 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
397 m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
398 m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
399 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
400 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
401
402 // Logging defaults
403 m.viper.SetDefault("logging.level", defaults.Logging.Level)
404 m.viper.SetDefault("logging.format", defaults.Logging.Format)
405 m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
406 m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
407 m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
408 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
409 m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
410 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
411 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
412 m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout)
413 m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr)
414 m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput)
415 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
416 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
417
418 // Appearance defaults
419 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
420 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
421 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
422 m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
423
424 // Video acceleration defaults
425 m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
426 m.viper.SetDefault("video_acceleration.auto_detect_gpu", defaults.VideoAcceleration.AutoDetectGPU)
427 m.viper.SetDefault("video_acceleration.vaapi_driver_name", defaults.VideoAcceleration.VAAPIDriverName)
428 m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
429 m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
430
431 // Rendering defaults
432 m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
433}
434
435// createDefaultConfig creates a default configuration file.
436func (m *Manager) createDefaultConfig() error {
437 configFile, err := GetConfigFile()
438 if err != nil {
439 return err
440 }
441
442 // Create config directory if it doesn't exist
443 if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
444 return err
445 }
446
447 // Get the default configuration
448 defaultConfig := DefaultConfig()
449
450 // Marshal to JSON with proper indentation
451 configData, err := json.MarshalIndent(defaultConfig, "", " ")
452 if err != nil {
453 return fmt.Errorf("failed to marshal default config: %w", err)
454 }
455
456 // Write JSON config file
457 if err := os.WriteFile(configFile, configData, filePerm); err != nil {
458 return fmt.Errorf("failed to write config file: %w", err)
459 }
460
461 fmt.Printf("Created default configuration file: %s\n", configFile)
462 return nil
463}
464
465// GetConfigFile returns the path to the configuration file being used.
466func (m *Manager) GetConfigFile() string {
467 return m.viper.ConfigFileUsed()
468}
469
470// Global configuration manager instance
471var globalManager *Manager
472var globalManagerOnce sync.Once
473
474// Init initializes the global configuration manager.
475func Init() error {
476 var err error
477 globalManagerOnce.Do(func() {
478 globalManager, err = NewManager()
479 if err != nil {
480 return
481 }
482 err = globalManager.Load()
483 })
484 return err
485}
486
487// Get returns the global configuration.
488func Get() *Config {
489 if globalManager == nil {
490 // Return defaults if not initialized
491 return DefaultConfig()
492 }
493 return globalManager.Get()
494}
495
496// Watch starts watching the global configuration for changes.
497func Watch() error {
498 if globalManager == nil {
499 return fmt.Errorf("configuration not initialized")
500 }
501 return globalManager.Watch()
502}
503
504// OnConfigChange registers a callback for global configuration changes.
505func OnConfigChange(callback func(*Config)) {
506 if globalManager == nil {
507 return
508 }
509 globalManager.OnConfigChange(callback)
510}