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