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