1package config
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/infrastructure/fonts"
14 "github.com/bnema/dumber/internal/logging"
15 "github.com/spf13/viper"
16)
17
18// Manager handles configuration loading, watching, and reloading.
19type Manager struct {
20 config *Config
21 viper *viper.Viper
22 automaticEnv automaticEnvConfig
23 mu sync.RWMutex
24 callbacks []func(*Config)
25 watching bool
26 skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
27}
28
29type automaticEnvConfig struct {
30 enabled bool
31 prefix string
32 keyReplacer *strings.Replacer
33}
34
35// NewManager creates a new configuration manager.
36func NewManager() (*Manager, error) {
37 v := viper.New()
38 m := &Manager{
39 viper: v,
40 callbacks: make([]func(*Config), 0),
41 }
42
43 // Configure Viper for TOML as default format
44 v.SetConfigName("config") // Name without extension
45 v.SetConfigType("toml") // TOML as default format
46
47 // Add config paths
48 configDir, err := GetConfigDir()
49 if err != nil {
50 return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
51 }
52 v.AddConfigPath(configDir)
53 v.AddConfigPath(".") // Current directory for development
54
55 // Set up environment variable support
56 m.configureAutomaticEnv()
57
58 // Note: Most environment variables are handled automatically via AutomaticEnv()
59 // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
60 // The explicit bindings below are only for special cases with different naming patterns.
61
62 // Explicit bindings for legacy or system env vars
63 if err := v.BindEnv("default_webpage_zoom", "DUMBER_DEFAULT_WEBPAGE_ZOOM"); err != nil {
64 return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_WEBPAGE_ZOOM: %w", err)
65 }
66 if err := v.BindEnv("default_ui_scale", "DUMBER_DEFAULT_UI_SCALE"); err != nil {
67 return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_UI_SCALE: %w", err)
68 }
69
70 // Logging environment variable bindings
71 if err := v.BindEnv("logging.level", "DUMBER_LOG_LEVEL"); err != nil {
72 return nil, fmt.Errorf("failed to bind DUMBER_LOG_LEVEL: %w", err)
73 }
74 if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
75 return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
76 }
77 if err := bindBrowsingContextEnvAliases(v); err != nil {
78 return nil, err
79 }
80
81 return m, nil
82}
83
84// Load loads the configuration from file and environment variables.
85func (m *Manager) Load() error {
86 m.mu.Lock()
87 defer m.mu.Unlock()
88
89 if err := EnsureDirectories(); err != nil {
90 return fmt.Errorf("failed to ensure directories: %w", err)
91 }
92
93 m.setDefaults()
94
95 if err := m.readConfigFile(); err != nil {
96 return err
97 }
98
99 if err := m.checkLegacyFormat(); err != nil {
100 return err
101 }
102
103 config, err := m.unmarshalConfig()
104 if err != nil {
105 return err
106 }
107 if err := ensureDatabasePath(config); err != nil {
108 return err
109 }
110 normalizeConfig(config)
111
112 if err := validateConfig(config); err != nil {
113 return fmt.Errorf("configuration validation failed: %w", err)
114 }
115
116 m.config = config
117 return nil
118}
119
120func (m *Manager) readConfigFile() error {
121 if err := m.viper.ReadInConfig(); err != nil {
122 var configFileNotFoundError viper.ConfigFileNotFoundError
123 if errors.As(err, &configFileNotFoundError) {
124 if createErr := m.createDefaultConfig(); createErr != nil {
125 configDir, _ := GetConfigDir()
126 return fmt.Errorf(
127 "failed to create default config at %s: %w\nTry creating the directory manually or check permissions",
128 configDir,
129 createErr,
130 )
131 }
132 if rereadErr := m.viper.ReadInConfig(); rereadErr != nil {
133 return fmt.Errorf(
134 "failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format",
135 rereadErr,
136 )
137 }
138 } else {
139 configFile := m.viper.ConfigFileUsed()
140 if configFile == "" {
141 configDir, _ := GetConfigDir()
142 configFile = filepath.Join(configDir, "config.toml")
143 }
144 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
145 }
146 }
147
148 // Transform legacy action bindings and engine config before unmarshaling.
149 m.transformLegacyConfig()
150
151 return nil
152}
153
154// transformLegacyConfig converts old-format action bindings and engine config to the new format.
155// This is called after reading config but before unmarshaling.
156func (m *Manager) transformLegacyConfig() {
157 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
158
159 rawConfig := m.viper.AllSettings()
160 transformer := NewLegacyConfigTransformer()
161 transformer.TransformLegacyActions(rawConfig)
162 transformer.TransformLegacyEngineConfigWithExplicitAdaptive(
163 rawConfig,
164 m.hasExplicitAdaptiveWindowlessFrameRateSetting(),
165 m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"),
166 )
167
168 // AllSettings includes defaults, so workspace.browsing_contexts may already be
169 // present even when the user only configured legacy workspace.popups. Always
170 // merge legacy popup values field-by-field into the canonical section while
171 // preserving explicit canonical file/env overrides.
172 if legacyPopupsInConfig {
173 m.mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig)
174 } else {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }
177
178 // Merge newly added default actions into in-memory config so existing persisted
179 // action maps keep working before the user writes a migrated config to disk.
180 configMigrator.mergeMissingDefaultActions(rawConfig)
181
182 // Apply transformed config back to viper
183 for key, value := range rawConfig {
184 m.viper.Set(key, value)
185 }
186}
187
188func (m *Manager) configureAutomaticEnv() {
189 if m == nil || m.viper == nil {
190 return
191 }
192 replacer := strings.NewReplacer(".", "_")
193 m.viper.SetEnvPrefix("DUMBER")
194 m.viper.SetEnvKeyReplacer(replacer)
195 m.viper.AutomaticEnv()
196 m.automaticEnv = automaticEnvConfig{
197 enabled: true,
198 prefix: "DUMBER",
199 keyReplacer: replacer,
200 }
201}
202
203func (m *Manager) hasExplicitAdaptiveWindowlessFrameRateSetting() bool {
204 if m == nil || m.viper == nil {
205 return false
206 }
207 if m.viper.InConfig("engine.cef.adaptive_windowless_frame_rate") {
208 return true
209 }
210 if !m.automaticEnv.enabled {
211 return false
212 }
213 envKey := "engine.cef.adaptive_windowless_frame_rate"
214 if m.automaticEnv.keyReplacer != nil {
215 envKey = m.automaticEnv.keyReplacer.Replace(envKey)
216 }
217 envKey = strings.ToUpper(envKey)
218 if prefix := strings.TrimSpace(m.automaticEnv.prefix); prefix != "" {
219 envKey = prefix + "_" + envKey
220 }
221 value, ok := os.LookupEnv(envKey)
222 return ok && value != ""
223}
224
225func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) {
226 workspace, ok := rawConfig["workspace"].(map[string]any)
227 if !ok {
228 return
229 }
230
231 legacyPopups, ok := workspace["popups"].(map[string]any)
232 if !ok {
233 return
234 }
235
236 canonical, ok := workspace["browsing_contexts"].(map[string]any)
237 if !ok || canonical == nil {
238 canonical = make(map[string]any, len(legacyPopups))
239 workspace["browsing_contexts"] = canonical
240 }
241
242 for key, value := range legacyPopups {
243 if m.hasCanonicalBrowsingContextsOverride(key) {
244 continue
245 }
246 canonical[key] = value
247 }
248
249 delete(workspace, "popups")
250}
251
252func (m *Manager) hasCanonicalBrowsingContextsOverride(field string) bool {
253 if m.viper.InConfig("workspace.browsing_contexts." + field) {
254 return true
255 }
256 return m.hasCanonicalBrowsingContextsEnvOverride(field)
257}
258
259func (*Manager) hasCanonicalBrowsingContextsEnvOverride(field string) bool {
260 suffix := strings.ToUpper(strings.ReplaceAll(field, ".", "_"))
261 for _, envKey := range []string{
262 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_" + suffix,
263 "DUMBER_WORKSPACE_POPUPS_" + suffix,
264 } {
265 if _, ok := os.LookupEnv(envKey); ok {
266 return true
267 }
268 }
269 return false
270}
271
272func bindBrowsingContextEnvAliases(v *viper.Viper) error {
273 bindings := map[string][]string{
274 "workspace.browsing_contexts.behavior": {
275 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_BEHAVIOR",
276 "DUMBER_WORKSPACE_POPUPS_BEHAVIOR",
277 },
278 "workspace.browsing_contexts.placement": {
279 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_PLACEMENT",
280 "DUMBER_WORKSPACE_POPUPS_PLACEMENT",
281 },
282 "workspace.browsing_contexts.open_in_new_pane": {
283 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_OPEN_IN_NEW_PANE",
284 "DUMBER_WORKSPACE_POPUPS_OPEN_IN_NEW_PANE",
285 },
286 "workspace.browsing_contexts.follow_pane_context": {
287 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_FOLLOW_PANE_CONTEXT",
288 "DUMBER_WORKSPACE_POPUPS_FOLLOW_PANE_CONTEXT",
289 },
290 "workspace.browsing_contexts.blank_target_behavior": {
291 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_BLANK_TARGET_BEHAVIOR",
292 "DUMBER_WORKSPACE_POPUPS_BLANK_TARGET_BEHAVIOR",
293 },
294 "workspace.browsing_contexts.enable_smart_detection": {
295 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_ENABLE_SMART_DETECTION",
296 "DUMBER_WORKSPACE_POPUPS_ENABLE_SMART_DETECTION",
297 },
298 "workspace.browsing_contexts.oauth_auto_close": {
299 "DUMBER_WORKSPACE_BROWSING_CONTEXTS_OAUTH_AUTO_CLOSE",
300 "DUMBER_WORKSPACE_POPUPS_OAUTH_AUTO_CLOSE",
301 },
302 }
303 for key, envs := range bindings {
304 args := append([]string{key}, envs...)
305 if err := v.BindEnv(args...); err != nil {
306 return fmt.Errorf("failed to bind browsing-context env aliases for %s: %w", key, err)
307 }
308 }
309 return nil
310}
311
312// checkLegacyFormat detects old config format and returns an error directing user to migrate.
313func (m *Manager) checkLegacyFormat() error {
314 // IsSet works for legacy keys because they do not have defaults, while
315 // engine.type has a default and must be checked with InConfig.
316 hasOldSections := false
317 for _, alias := range legacyEngineAliases {
318 if m.viper.IsSet(alias.legacyKey()) {
319 hasOldSections = true
320 break
321 }
322 }
323
324 hasEngineSection := m.hasEngineSectionInConfig()
325
326 if hasOldSections && hasEngineSection {
327 return fmt.Errorf(
328 "mixed old/new config: [engine] coexists with legacy engine keys in %s; "+
329 "remove legacy keys or migrate from a purely legacy config",
330 legacyEngineInputSectionsMessage(),
331 )
332 }
333 if hasOldSections {
334 return fmt.Errorf(
335 "config format outdated: legacy engine keys in %s have moved to "+
336 "[engine]/[engine.webkit] — run \"dumber config migrate\" "+
337 "to update your config file",
338 legacyEngineInputSectionsMessage(),
339 )
340 }
341 return nil
342}
343
344func (m *Manager) hasEngineSectionInConfig() bool {
345 if configFile := m.viper.ConfigFileUsed(); configFile != "" {
346 raw, err := readRawTOML(configFile)
347 if err == nil {
348 _, exists := raw["engine"]
349 return exists
350 }
351 }
352 return m.viper.InConfig("engine.type")
353}
354
355func (m *Manager) unmarshalConfig() (*Config, error) {
356 config := &Config{}
357 if err := m.viper.Unmarshal(config); err != nil {
358 configFile := m.viper.ConfigFileUsed()
359 return nil, fmt.Errorf(
360 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
361 configFile,
362 err,
363 )
364 }
365 return config, nil
366}
367
368func ensureDatabasePath(config *Config) error {
369 if config.Database.Path != "" {
370 return nil
371 }
372 dbPath, err := GetDatabaseFile()
373 if err != nil {
374 return fmt.Errorf("failed to get database path: %w", err)
375 }
376 config.Database.Path = dbPath
377 return nil
378}
379
380func normalizeConfig(config *Config) {
381 normalizeAppearance(config)
382 normalizeMedia(config)
383 normalizeEngineConfig(config)
384 normalizeBrowsingContexts(config)
385}
386
387// normalizeBrowsingContexts ensures the deprecated Popups field mirrors BrowsingContexts
388// for runtime compatibility. Existing code can keep reading config.Workspace.Popups.
389func normalizeBrowsingContexts(config *Config) {
390 config.Workspace.Popups = config.Workspace.BrowsingContexts
391}
392
393func normalizeEngineConfig(config *Config) {
394 // Normalize GSK renderer (engine.webkit)
395 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
396 case "", string(GSKRendererAuto):
397 config.Engine.WebKit.GSKRenderer = GSKRendererAuto
398 case string(GSKRendererOpenGL):
399 config.Engine.WebKit.GSKRenderer = GSKRendererOpenGL
400 case string(GSKRendererVulkan):
401 config.Engine.WebKit.GSKRenderer = GSKRendererVulkan
402 case string(GSKRendererCairo):
403 config.Engine.WebKit.GSKRenderer = GSKRendererCairo
404 default:
405 config.Engine.WebKit.GSKRenderer = GSKRendererAuto
406 }
407
408 // Normalize cookie policy (engine).
409 // When no value (or an invalid value) is set, respect the ITP setting: if ITP is
410 // disabled the effective default is to block third-party cookies; if ITP is enabled
411 // all cookies are accepted by default. An explicit "always" value always wins.
412 itpDefault := func() CookiePolicy {
413 if config.Engine.WebKit.ITPEnabled {
414 return CookiePolicyAlways
415 }
416 return CookiePolicyNoThirdParty
417 }
418 switch strings.ToLower(string(config.Engine.CookiePolicy)) {
419 case "":
420 config.Engine.CookiePolicy = itpDefault()
421 case string(CookiePolicyAlways):
422 config.Engine.CookiePolicy = CookiePolicyAlways
423 case string(CookiePolicyNoThirdParty):
424 config.Engine.CookiePolicy = CookiePolicyNoThirdParty
425 case string(CookiePolicyNever):
426 config.Engine.CookiePolicy = CookiePolicyNever
427 default:
428 config.Engine.CookiePolicy = itpDefault()
429 }
430
431 // Normalize performance profile (engine)
432 switch strings.ToLower(string(config.Engine.Profile)) {
433 case "", string(ProfileDefault):
434 config.Engine.Profile = ProfileDefault
435 case string(ProfileLite):
436 config.Engine.Profile = ProfileLite
437 case string(ProfileBalanced):
438 config.Engine.Profile = ProfileBalanced
439 case string(ProfileMax):
440 config.Engine.Profile = ProfileMax
441 case string(ProfileCustom):
442 config.Engine.Profile = ProfileCustom
443 default:
444 config.Engine.Profile = ProfileDefault
445 }
446
447 // Trim runtime prefix (engine.webkit)
448 config.Engine.WebKit.Prefix = strings.TrimSpace(config.Engine.WebKit.Prefix)
449}
450
451func normalizeAppearance(config *Config) {
452 config.Appearance.ExternalTheme.Provider = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Provider))
453 config.Appearance.ExternalTheme.Format = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Format))
454 config.Appearance.ExternalTheme.Path = strings.TrimSpace(config.Appearance.ExternalTheme.Path)
455 if config.Appearance.ExternalTheme.Provider == "" {
456 config.Appearance.ExternalTheme.Provider = defaultExternalThemeProvider
457 }
458 if config.Appearance.ExternalTheme.Format == "" {
459 config.Appearance.ExternalTheme.Format = defaultExternalThemeFormat
460 }
461 if config.Appearance.ExternalTheme.Enabled &&
462 config.Appearance.ExternalTheme.Provider == defaultExternalThemeProvider &&
463 isSupportedExternalThemeFormat(config.Appearance.ExternalTheme.Format) &&
464 config.Appearance.ExternalTheme.Path == "" {
465 config.Appearance.ExternalTheme.Path = getDefaultExternalThemePathForFormat(config.Appearance.ExternalTheme.Format)
466 }
467
468 switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
469 case strings.ToLower(ThemePreferDark):
470 config.Appearance.ColorScheme = ThemePreferDark
471 case strings.ToLower(ThemePreferLight):
472 config.Appearance.ColorScheme = ThemePreferLight
473 case strings.ToLower(ThemeDefault):
474 config.Appearance.ColorScheme = ThemeDefault
475 default:
476 config.Appearance.ColorScheme = ThemeDefault
477 }
478}
479
480func normalizeMedia(config *Config) {
481 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
482 case "", string(HardwareDecodingAuto):
483 config.Media.HardwareDecodingMode = HardwareDecodingAuto
484 case string(HardwareDecodingForce):
485 config.Media.HardwareDecodingMode = HardwareDecodingForce
486 case string(HardwareDecodingDisable):
487 config.Media.HardwareDecodingMode = HardwareDecodingDisable
488 default:
489 config.Media.HardwareDecodingMode = HardwareDecodingAuto
490 }
491}
492
493// Get returns the current configuration (thread-safe).
494func (m *Manager) Get() *Config {
495 m.mu.RLock()
496 defer m.mu.RUnlock()
497
498 // Return a copy to prevent external modification
499 configCopy := *m.config
500 return &configCopy
501}
502
503// Save saves the provided configuration to disk with ordered sections.
504func (m *Manager) Save(cfg *Config) error {
505 m.mu.Lock()
506 defer m.mu.Unlock()
507
508 if cfg == nil {
509 return fmt.Errorf("config is nil")
510 }
511
512 // Normalize a copy before writing/publishing so deprecated runtime aliases
513 // stay in sync even when the watcher skips reload after Save().
514 cfgToSave := *cfg
515 normalizeConfig(&cfgToSave)
516
517 // Validate before writing so UI gets immediate errors.
518 if err := validateConfig(&cfgToSave); err != nil {
519 return fmt.Errorf("configuration validation failed: %w", err)
520 }
521
522 // Write config with ordered sections (bypasses Viper's random map ordering)
523 configFile := m.viper.ConfigFileUsed()
524 if configFile == "" {
525 var err error
526 configFile, err = GetConfigFile()
527 if err != nil {
528 return fmt.Errorf("failed to determine config file path: %w", err)
529 }
530 }
531
532 // Set flag BEFORE writing to prevent fsnotify-triggered reload from
533 // overwriting our in-memory config with stale viper cache
534 m.skipNextReload = true
535
536 if err := WriteConfigOrdered(&cfgToSave, configFile); err != nil {
537 m.skipNextReload = false // Reset on error
538 return fmt.Errorf("failed to write config: %w", err)
539 }
540
541 // Update in-memory config immediately (don't wait for file watcher).
542 // This ensures subsequent Get() calls return normalized values even when
543 // the watcher intentionally skips reload for our own Save().
544 m.config = &cfgToSave
545
546 // Also reload from disk to sync Viper's internal state (if not watching)
547 if !m.watching {
548 m.skipNextReload = false // Clear flag since we're doing manual reload
549 if err := m.reload(); err != nil {
550 return err
551 }
552 }
553
554 return nil
555}
556
557// GetConfigFile returns the path to the configuration file being used.
558func (m *Manager) GetConfigFile() string {
559 return m.viper.ConfigFileUsed()
560}
561
562// createDefaultConfig creates a default configuration file with ordered sections.
563func (m *Manager) createDefaultConfig() error {
564 configFile, err := GetConfigFile()
565 if err != nil {
566 return err
567 }
568
569 // Create config directory if it doesn't exist
570 if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
571 return err
572 }
573
574 // Start with default config
575 cfg := DefaultConfig()
576
577 // Detect available fonts and override defaults
578 m.detectAndSetFontsOnConfig(cfg)
579
580 // Write config with ordered sections
581 if err := WriteConfigOrdered(cfg, configFile); err != nil {
582 return fmt.Errorf("failed to write config file: %w", err)
583 }
584
585 fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
586
587 return nil
588}
589
590// detectAndSetFontsOnConfig detects available system fonts and sets the best available
591// fonts from the fallback chains directly on the Config struct.
592func (*Manager) detectAndSetFontsOnConfig(cfg *Config) {
593 // Create context with logger for debugging first-run font detection.
594 logger := logging.NewFromEnv()
595 ctx := logging.WithContext(context.Background(), logger)
596
597 detector := fonts.NewDetector()
598
599 if !detector.IsAvailable(ctx) {
600 // fc-list not available, keep hardcoded defaults
601 return
602 }
603
604 cfg.Appearance.SansFont = detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
605 cfg.Appearance.SerifFont = detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
606 cfg.Appearance.MonospaceFont = detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
607}
608
609// setDefaults sets default configuration values in Viper.
610func (m *Manager) setDefaults() {
611 defaults := DefaultConfig()
612
613 // Note: Database.Path is set dynamically in Load(), no defaults needed
614
615 m.setHistoryDefaults(defaults)
616 m.setSearchDefaults(defaults)
617 m.setDmenuDefaults(defaults)
618 m.setLoggingDefaults(defaults)
619 m.setDebugDefaults(defaults)
620 m.setAppearanceDefaults(defaults)
621 m.setEngineDefaults(defaults)
622 m.setZoomAndScaleDefaults(defaults)
623 m.setWorkspaceDefaults(defaults)
624 m.setContentFilteringDefaults(defaults)
625 m.setClipboardDefaults(defaults)
626 m.setOmniboxDefaults(defaults)
627 m.setMediaDefaults(defaults)
628 m.setSessionDefaults(defaults)
629 m.setUpdateDefaults(defaults)
630 m.setDownloadsDefaults(defaults)
631}
632
633func (m *Manager) setHistoryDefaults(defaults *Config) {
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
639func (m *Manager) setSearchDefaults(defaults *Config) {
640 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
641 m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
642}
643
644func (m *Manager) setDmenuDefaults(defaults *Config) {
645 m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
646 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
647 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
648 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
649 m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
650 m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
651 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
652 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
653}
654
655func (m *Manager) setLoggingDefaults(defaults *Config) {
656 m.viper.SetDefault("logging.level", defaults.Logging.Level)
657 m.viper.SetDefault("logging.format", defaults.Logging.Format)
658 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
659 m.viper.SetDefault("logging.max_files", defaults.Logging.MaxFiles)
660 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
661 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
662 m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
663 m.viper.SetDefault("logging.capture_gtk_logs", defaults.Logging.CaptureGTKLogs)
664}
665
666func (m *Manager) setDebugDefaults(defaults *Config) {
667 m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools)
668}
669
670func (m *Manager) setAppearanceDefaults(defaults *Config) {
671 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
672 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
673 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
674 m.viper.SetDefault("appearance.gtk_font", defaults.Appearance.GtkFont)
675 m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
676 m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
677 m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
678 m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
679 m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled)
680 m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider)
681 m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format)
682}
683
684func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
685 m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
686 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
687 m.viper.SetDefault("sidebar_width", defaults.SidebarWidth)
688}
689
690func (m *Manager) setWorkspaceDefaults(defaults *Config) {
691 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
692 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
693 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
694 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
695 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
696 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
697 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
698 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
699 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
700 m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
701 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
702 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
703 m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
704 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
705 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
706 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
707 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
708 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
709 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
710 m.viper.SetDefault("workspace.browsing_contexts.behavior", string(defaults.Workspace.BrowsingContexts.Behavior))
711 m.viper.SetDefault("workspace.browsing_contexts.placement", defaults.Workspace.BrowsingContexts.Placement)
712 m.viper.SetDefault("workspace.browsing_contexts.open_in_new_pane", defaults.Workspace.BrowsingContexts.OpenInNewPane)
713 m.viper.SetDefault("workspace.browsing_contexts.follow_pane_context", defaults.Workspace.BrowsingContexts.FollowPaneContext)
714 m.viper.SetDefault("workspace.browsing_contexts.blank_target_behavior", defaults.Workspace.BrowsingContexts.BlankTargetBehavior)
715 m.viper.SetDefault("workspace.browsing_contexts.enable_smart_detection", defaults.Workspace.BrowsingContexts.EnableSmartDetection)
716 m.viper.SetDefault("workspace.browsing_contexts.oauth_auto_close", defaults.Workspace.BrowsingContexts.OAuthAutoClose)
717 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
718 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
719 m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
720 m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
721 m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
722 m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
723 m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
724 m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
725 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
726}
727
728func (m *Manager) setContentFilteringDefaults(defaults *Config) {
729 m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
730 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
731}
732
733func (m *Manager) setClipboardDefaults(defaults *Config) {
734 m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
735}
736
737func (m *Manager) setOmniboxDefaults(defaults *Config) {
738 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
739 m.viper.SetDefault("omnibox.most_visited_days", defaults.Omnibox.MostVisitedDays)
740 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
741}
742
743func (m *Manager) setMediaDefaults(defaults *Config) {
744 // GStreamer fields (force_vsync, gl_rendering_mode, gstreamer_debug_level)
745 // have moved to [engine.webkit] — only non-migrated media fields remain here.
746 m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
747 m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
748 m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
749}
750
751// setRuntimeDefaults removed — runtime.prefix moved to [engine.webkit].
752
753func (m *Manager) setSessionDefaults(defaults *Config) {
754 m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
755 m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
756 m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
757 m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
758 m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
759 m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
760 m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
761}
762
763func (m *Manager) setUpdateDefaults(defaults *Config) {
764 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
765 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
766 m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
767}
768
769// setPerformanceDefaults removed — all fields moved to [engine]/[engine.webkit].
770
771func (m *Manager) setDownloadsDefaults(defaults *Config) {
772 m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
773}
774
775func (m *Manager) setEngineDefaults(defaults *Config) {
776 e := defaults.Engine
777 m.viper.SetDefault("engine.type", e.Type)
778 m.viper.SetDefault("engine.profile", string(e.Profile))
779 m.viper.SetDefault("engine.pool_prewarm_count", e.PoolPrewarmCount)
780 m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
781 m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
782
783 ce := e.CEF
784 m.viper.SetDefault("engine.cef.cef_dir", ce.CEFDir)
785 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
786 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
787 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
788 m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
789 m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
790 m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
791 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
792 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
793 m.setCEFInputDefaults(ce.Input)
794
795 wk := e.WebKit
796 m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
797 m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
798 m.viper.SetDefault("engine.webkit.skia_gpu_painting_threads", wk.SkiaGPUPaintingThreads)
799 m.viper.SetDefault("engine.webkit.skia_enable_cpu_rendering", wk.SkiaEnableCPURendering)
800 m.viper.SetDefault("engine.webkit.disable_dmabuf_renderer", wk.DisableDMABufRenderer)
801 m.viper.SetDefault("engine.webkit.force_compositing_mode", wk.ForceCompositingMode)
802 m.viper.SetDefault("engine.webkit.disable_compositing_mode", wk.DisableCompositingMode)
803 m.viper.SetDefault("engine.webkit.gsk_renderer", string(wk.GSKRenderer))
804 m.viper.SetDefault("engine.webkit.disable_mipmaps", wk.DisableMipmaps)
805 m.viper.SetDefault("engine.webkit.prefer_gl", wk.PreferGL)
806 m.viper.SetDefault("engine.webkit.draw_compositing_indicators", wk.DrawCompositingIndicators)
807 m.viper.SetDefault("engine.webkit.show_fps", wk.ShowFPS)
808 m.viper.SetDefault("engine.webkit.sample_memory", wk.SampleMemory)
809 m.viper.SetDefault("engine.webkit.debug_frames", wk.DebugFrames)
810 m.viper.SetDefault("engine.webkit.force_vsync", wk.ForceVSync)
811 m.viper.SetDefault("engine.webkit.gl_rendering_mode", string(wk.GLRenderingMode))
812 m.viper.SetDefault("engine.webkit.gstreamer_debug_level", wk.GStreamerDebugLevel)
813 m.viper.SetDefault("engine.webkit.prefix", wk.Prefix)
814 m.viper.SetDefault("engine.webkit.web_process_memory_limit_mb", wk.WebProcessMemoryLimitMB)
815 m.viper.SetDefault("engine.webkit.web_process_memory_poll_interval_sec", wk.WebProcessMemoryPollIntervalSec)
816 m.viper.SetDefault("engine.webkit.web_process_memory_conservative_threshold", wk.WebProcessMemoryConservativeThreshold)
817 m.viper.SetDefault("engine.webkit.web_process_memory_strict_threshold", wk.WebProcessMemoryStrictThreshold)
818 m.viper.SetDefault("engine.webkit.network_process_memory_limit_mb", wk.NetworkProcessMemoryLimitMB)
819 m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
820 m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
821 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
822}
823
824func (m *Manager) setCEFInputDefaults(input CEFInputConfig) {
825 m.viper.SetDefault("engine.cef.input.scroll_wheel_multiplier", input.ScrollWheelMultiplier)
826 m.viper.SetDefault("engine.cef.input.scroll_precise_multiplier", input.ScrollPreciseMultiplier)
827 m.viper.SetDefault("engine.cef.input.scroll_horizontal_multiplier", input.ScrollHorizontalMultiplier)
828 m.viper.SetDefault("engine.cef.input.scroll_vertical_multiplier", input.ScrollVerticalMultiplier)
829 m.viper.SetDefault("engine.cef.input.scroll_max_delta", input.ScrollMaxDelta)
830 m.viper.SetDefault("engine.cef.input.touchpad_navigation_enabled", input.TouchpadNavigationEnabled)
831 m.viper.SetDefault("engine.cef.input.touchpad_navigation_min_delta", input.TouchpadNavigationMinDelta)
832 m.viper.SetDefault("engine.cef.input.touchpad_navigation_max_vertical_ratio", input.TouchpadNavigationMaxVerticalRatio)
833}
834
835// New returns a new default configuration instance.
836// This is a convenience function for getting default config without the full manager.
837func New() *Config {
838 return DefaultConfig()
839}
840
841// Global configuration manager instance
842var globalManager *Manager
843var globalManagerOnce sync.Once
844
845// Init initializes the global configuration manager.
846func Init() error {
847 var err error
848 globalManagerOnce.Do(func() {
849 globalManager, err = NewManager()
850 if err != nil {
851 return
852 }
853 err = globalManager.Load()
854 })
855 return err
856}
857
858// Get returns the global configuration.
859func Get() *Config {
860 if globalManager == nil {
861 // Return defaults if not initialized
862 return DefaultConfig()
863 }
864 return globalManager.Get()
865}
866
867// GetManager returns the global configuration manager.
868// This is useful for accessing watcher functionality.
869func GetManager() *Manager {
870 return globalManager
871}