1package config
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10
11 "github.com/spf13/viper"
12)
13
14// Manager handles configuration loading, watching, and reloading.
15type Manager struct {
16 config *Config
17 viper *viper.Viper
18 mu sync.RWMutex
19 callbacks []func(*Config)
20 watching bool
21}
22
23// NewManager creates a new configuration manager.
24func NewManager() (*Manager, error) {
25 v := viper.New()
26
27 // Configure Viper for TOML as default format
28 v.SetConfigName("config") // Name without extension
29 v.SetConfigType("toml") // TOML as default format
30
31 // Add config paths
32 configDir, err := GetConfigDir()
33 if err != nil {
34 return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
35 }
36 v.AddConfigPath(configDir)
37 v.AddConfigPath(".") // Current directory for development
38
39 // Set up environment variable support
40 v.SetEnvPrefix("DUMBER")
41 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
42 v.AutomaticEnv()
43
44 // Note: Most environment variables are handled automatically via AutomaticEnv()
45 // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
46 // The explicit bindings below are only for special cases with different naming patterns.
47
48 // Explicit bindings for legacy or system env vars
49 if err := v.BindEnv("default_webpage_zoom", "DUMBER_DEFAULT_WEBPAGE_ZOOM"); err != nil {
50 return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_WEBPAGE_ZOOM: %w", err)
51 }
52 if err := v.BindEnv("default_ui_scale", "DUMBER_DEFAULT_UI_SCALE"); err != nil {
53 return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_UI_SCALE: %w", err)
54 }
55
56 // Logging environment variable bindings
57 if err := v.BindEnv("logging.level", "DUMBER_LOG_LEVEL"); err != nil {
58 return nil, fmt.Errorf("failed to bind DUMBER_LOG_LEVEL: %w", err)
59 }
60 if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
61 return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
62 }
63
64 return &Manager{
65 viper: v,
66 callbacks: make([]func(*Config), 0),
67 }, nil
68}
69
70// Load loads the configuration from file and environment variables.
71func (m *Manager) Load() error {
72 m.mu.Lock()
73 defer m.mu.Unlock()
74
75 if err := EnsureDirectories(); err != nil {
76 return fmt.Errorf("failed to ensure directories: %w", err)
77 }
78
79 m.setDefaults()
80
81 if err := m.readConfigFile(); err != nil {
82 return err
83 }
84
85 config, err := m.unmarshalConfig()
86 if err != nil {
87 return err
88 }
89 if err := ensureDatabasePath(config); err != nil {
90 return err
91 }
92 normalizeConfig(config)
93
94 if err := validateConfig(config); err != nil {
95 return fmt.Errorf("configuration validation failed: %w", err)
96 }
97
98 m.config = config
99 return nil
100}
101
102func (m *Manager) readConfigFile() error {
103 if err := m.viper.ReadInConfig(); err != nil {
104 var configFileNotFoundError viper.ConfigFileNotFoundError
105 if errors.As(err, &configFileNotFoundError) {
106 if createErr := m.createDefaultConfig(); createErr != nil {
107 configDir, _ := GetConfigDir()
108 return fmt.Errorf(
109 "failed to create default config at %s: %w\nTry creating the directory manually or check permissions",
110 configDir,
111 createErr,
112 )
113 }
114 if rereadErr := m.viper.ReadInConfig(); rereadErr != nil {
115 return fmt.Errorf(
116 "failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format",
117 rereadErr,
118 )
119 }
120 } else {
121 configFile := m.viper.ConfigFileUsed()
122 if configFile == "" {
123 configDir, _ := GetConfigDir()
124 configFile = filepath.Join(configDir, "config.toml")
125 }
126 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
127 }
128 }
129 return nil
130}
131
132func (m *Manager) unmarshalConfig() (*Config, error) {
133 config := &Config{}
134 if err := m.viper.Unmarshal(config); err != nil {
135 configFile := m.viper.ConfigFileUsed()
136 return nil, fmt.Errorf(
137 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
138 configFile,
139 err,
140 )
141 }
142 return config, nil
143}
144
145func ensureDatabasePath(config *Config) error {
146 if config.Database.Path != "" {
147 return nil
148 }
149 dbPath, err := GetDatabaseFile()
150 if err != nil {
151 return fmt.Errorf("failed to get database path: %w", err)
152 }
153 config.Database.Path = dbPath
154 return nil
155}
156
157func normalizeConfig(config *Config) {
158 switch strings.ToLower(string(config.Rendering.Mode)) {
159 case "", string(RenderingModeAuto):
160 config.Rendering.Mode = RenderingModeAuto
161 case string(RenderingModeGPU):
162 config.Rendering.Mode = RenderingModeGPU
163 case string(RenderingModeCPU):
164 config.Rendering.Mode = RenderingModeCPU
165 default:
166 config.Rendering.Mode = RenderingModeAuto
167 }
168
169 switch strings.ToLower(string(config.Rendering.GSKRenderer)) {
170 case "", string(GSKRendererAuto):
171 config.Rendering.GSKRenderer = GSKRendererAuto
172 case string(GSKRendererOpenGL):
173 config.Rendering.GSKRenderer = GSKRendererOpenGL
174 case string(GSKRendererVulkan):
175 config.Rendering.GSKRenderer = GSKRendererVulkan
176 case string(GSKRendererCairo):
177 config.Rendering.GSKRenderer = GSKRendererCairo
178 default:
179 config.Rendering.GSKRenderer = GSKRendererAuto
180 }
181
182 switch config.Appearance.ColorScheme {
183 case ThemePreferDark, ThemePreferLight, ThemeDefault:
184 case "":
185 config.Appearance.ColorScheme = ThemeDefault
186 default:
187 config.Appearance.ColorScheme = ThemeDefault
188 }
189
190 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
191 case "", string(HardwareDecodingAuto):
192 config.Media.HardwareDecodingMode = HardwareDecodingAuto
193 case string(HardwareDecodingForce):
194 config.Media.HardwareDecodingMode = HardwareDecodingForce
195 case string(HardwareDecodingDisable):
196 config.Media.HardwareDecodingMode = HardwareDecodingDisable
197 default:
198 config.Media.HardwareDecodingMode = HardwareDecodingAuto
199 }
200
201 switch strings.ToLower(string(config.Media.GLRenderingMode)) {
202 case "", string(GLRenderingModeAuto):
203 config.Media.GLRenderingMode = GLRenderingModeAuto
204 case string(GLRenderingModeGLES2):
205 config.Media.GLRenderingMode = GLRenderingModeGLES2
206 case string(GLRenderingModeGL3):
207 config.Media.GLRenderingMode = GLRenderingModeGL3
208 case string(GLRenderingModeNone):
209 config.Media.GLRenderingMode = GLRenderingModeNone
210 default:
211 config.Media.GLRenderingMode = GLRenderingModeAuto
212 }
213
214 config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
215}
216
217// Get returns the current configuration (thread-safe).
218func (m *Manager) Get() *Config {
219 m.mu.RLock()
220 defer m.mu.RUnlock()
221
222 // Return a copy to prevent external modification
223 configCopy := *m.config
224 return &configCopy
225}
226
227// Save saves the provided configuration to disk and updates Viper.
228func (m *Manager) Save(cfg *Config) error {
229 m.mu.Lock()
230 defer m.mu.Unlock()
231
232 if cfg == nil {
233 return fmt.Errorf("config is nil")
234 }
235
236 // Validate before writing so UI gets immediate errors.
237 if err := validateConfig(cfg); err != nil {
238 return fmt.Errorf("configuration validation failed: %w", err)
239 }
240
241 // Update Viper with the new values.
242 // Since we can't easily update Viper from a struct automatically while preserving
243 // all settings, we update the main keys we care about.
244 m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont)
245 m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont)
246 m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont)
247 m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize)
248 m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme)
249 m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette)
250 m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette)
251 m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom)
252 m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
253 m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
254
255 if err := m.viper.WriteConfig(); err != nil {
256 return fmt.Errorf("failed to write config: %w", err)
257 }
258
259 // We don't update m.config here manually because viper.OnConfigChange
260 // will trigger reload() if Watch() is active.
261 // If Watch() is not active, we should call reload() manually.
262 if !m.watching {
263 if err := m.reload(); err != nil {
264 return err
265 }
266 }
267
268 return nil
269}
270
271// GetConfigFile returns the path to the configuration file being used.
272func (m *Manager) GetConfigFile() string {
273 return m.viper.ConfigFileUsed()
274}
275
276// createDefaultConfig creates a default configuration file.
277func (m *Manager) createDefaultConfig() error {
278 configFile, err := GetConfigFile()
279 if err != nil {
280 return err
281 }
282
283 // Create config directory if it doesn't exist
284 if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
285 return err
286 }
287
288 // Ensure TOML format and write config
289 m.viper.SetConfigType("toml")
290 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
291 return fmt.Errorf("failed to write config file: %w", err)
292 }
293
294 fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
295
296 return nil
297}
298
299// setDefaults sets default configuration values in Viper.
300func (m *Manager) setDefaults() {
301 defaults := DefaultConfig()
302
303 // Note: Database.Path is set dynamically in Load(), no defaults needed
304
305 m.setHistoryDefaults(defaults)
306 m.setSearchDefaults(defaults)
307 m.setDmenuDefaults(defaults)
308 m.setLoggingDefaults(defaults)
309 m.setDebugDefaults(defaults)
310 m.setAppearanceDefaults(defaults)
311 m.setRenderingDefaults(defaults)
312 m.setWorkspaceDefaults(defaults)
313 m.setContentFilteringDefaults(defaults)
314 m.setOmniboxDefaults(defaults)
315 m.setMediaDefaults(defaults)
316 m.setRuntimeDefaults(defaults)
317}
318
319func (m *Manager) setHistoryDefaults(defaults *Config) {
320 m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
321 m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
322 m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
323}
324
325func (m *Manager) setSearchDefaults(defaults *Config) {
326 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
327 m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
328}
329
330func (m *Manager) setDmenuDefaults(defaults *Config) {
331 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
332 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
333 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
334 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
335 m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
336 m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
337 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
338 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
339}
340
341func (m *Manager) setLoggingDefaults(defaults *Config) {
342 m.viper.SetDefault("logging.level", defaults.Logging.Level)
343 m.viper.SetDefault("logging.format", defaults.Logging.Format)
344 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
345 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
346 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
347 m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
348}
349
350func (m *Manager) setDebugDefaults(defaults *Config) {
351 m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools)
352}
353
354func (m *Manager) setAppearanceDefaults(defaults *Config) {
355 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
356 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
357 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
358 m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
359 m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
360 m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
361 m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
362}
363
364func (m *Manager) setRenderingDefaults(defaults *Config) {
365 m.viper.SetDefault("rendering.mode", string(defaults.Rendering.Mode))
366 m.viper.SetDefault("rendering.disable_dmabuf_renderer", defaults.Rendering.DisableDMABufRenderer)
367 m.viper.SetDefault("rendering.force_compositing_mode", defaults.Rendering.ForceCompositingMode)
368 m.viper.SetDefault("rendering.disable_compositing_mode", defaults.Rendering.DisableCompositingMode)
369 m.viper.SetDefault("rendering.gsk_renderer", string(defaults.Rendering.GSKRenderer))
370 m.viper.SetDefault("rendering.disable_mipmaps", defaults.Rendering.DisableMipmaps)
371 m.viper.SetDefault("rendering.prefer_gl", defaults.Rendering.PreferGL)
372 m.viper.SetDefault("rendering.draw_compositing_indicators", defaults.Rendering.DrawCompositingIndicators)
373 m.viper.SetDefault("rendering.show_fps", defaults.Rendering.ShowFPS)
374 m.viper.SetDefault("rendering.sample_memory", defaults.Rendering.SampleMemory)
375 m.viper.SetDefault("rendering.debug_frames", defaults.Rendering.DebugFrames)
376 m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
377 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
378}
379
380func (m *Manager) setWorkspaceDefaults(defaults *Config) {
381 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
382 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
383 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
384 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
385 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
386 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
387 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
388 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
389 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
390 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
391 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
392 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
393 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
394 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
395 m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
396 m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
397 m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
398 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
399 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
400 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
401 m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)
402 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
403 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
404 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
405 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406}
407
408func (m *Manager) setContentFilteringDefaults(defaults *Config) {
409 m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
410 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
411}
412
413func (m *Manager) setOmniboxDefaults(defaults *Config) {
414 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
415}
416
417func (m *Manager) setMediaDefaults(defaults *Config) {
418 m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
419 m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
420 m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
421 m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync)
422 m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode))
423 m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel)
424 m.viper.SetDefault("media.video_buffer_size_mb", defaults.Media.VideoBufferSizeMB)
425 m.viper.SetDefault("media.queue_buffer_time_sec", defaults.Media.QueueBufferTimeSec)
426}
427
428func (m *Manager) setRuntimeDefaults(defaults *Config) {
429 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430}
431
432// New returns a new default configuration instance.
433// This is a convenience function for getting default config without the full manager.
434func New() *Config {
435 return DefaultConfig()
436}
437
438// Global configuration manager instance
439var globalManager *Manager
440var globalManagerOnce sync.Once
441
442// Init initializes the global configuration manager.
443func Init() error {
444 var err error
445 globalManagerOnce.Do(func() {
446 globalManager, err = NewManager()
447 if err != nil {
448 return
449 }
450 err = globalManager.Load()
451 })
452 return err
453}
454
455// Get returns the global configuration.
456func Get() *Config {
457 if globalManager == nil {
458 // Return defaults if not initialized
459 return DefaultConfig()
460 }
461 return globalManager.Get()
462}
463
464// GetManager returns the global configuration manager.
465// This is useful for accessing watcher functionality.
466func GetManager() *Manager {
467 return globalManager
468}