detached 996f88b

feat config add Viper-based configuration system

commit
996f88b01eadb4173ce20295b2827af302c4c9c2
author
bnema · · 2025-12-09 21:53 UTC
parent
d8668b9
signature
unsigned
9 files · +1640 -16
M go.mod +12 -0
@@ -3,21 +3,33 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6+ github.com/fsnotify/fsnotify v1.9.0
67 github.com/ncruces/go-sqlite3 v0.30.3
78 github.com/pressly/goose/v3 v3.26.0
89 github.com/rs/zerolog v1.33.0
10+ github.com/spf13/viper v1.21.0
911 )
1012
1113 require (
14+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
1215 github.com/mattn/go-colorable v0.1.13 // indirect
1316 github.com/mattn/go-isatty v0.0.20 // indirect
1417 github.com/mfridman/interpolate v0.0.2 // indirect
1518 github.com/ncruces/julianday v1.0.0 // indirect
19+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
20+ github.com/sagikazarmark/locafero v0.11.0 // indirect
1621 github.com/sethvargo/go-retry v0.3.0 // indirect
22+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
23+ github.com/spf13/afero v1.15.0 // indirect
24+ github.com/spf13/cast v1.10.0 // indirect
25+ github.com/spf13/pflag v1.0.10 // indirect
26+ github.com/subosito/gotenv v1.6.0 // indirect
1727 github.com/tetratelabs/wazero v1.10.1 // indirect
1828 go.uber.org/multierr v1.11.0 // indirect
29+ go.yaml.in/yaml/v3 v3.0.4 // indirect
1930 golang.org/x/sync v0.18.0 // indirect
2031 golang.org/x/sys v0.38.0 // indirect
32+ golang.org/x/text v0.31.0 // indirect
2133 )
2234
2335 replace (
@@ -3,21 +3,33 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6+ github.com/fsnotify/fsnotify v1.9.0
6 github.com/ncruces/go-sqlite3 v0.30.37 github.com/ncruces/go-sqlite3 v0.30.3
7 github.com/pressly/goose/v3 v3.26.08 github.com/pressly/goose/v3 v3.26.0
8 github.com/rs/zerolog v1.33.09 github.com/rs/zerolog v1.33.0
10+ github.com/spf13/viper v1.21.0
9 )11 )
10 12
11 require (13 require (
14+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
12 github.com/mattn/go-colorable v0.1.13 // indirect15 github.com/mattn/go-colorable v0.1.13 // indirect
13 github.com/mattn/go-isatty v0.0.20 // indirect16 github.com/mattn/go-isatty v0.0.20 // indirect
14 github.com/mfridman/interpolate v0.0.2 // indirect17 github.com/mfridman/interpolate v0.0.2 // indirect
15 github.com/ncruces/julianday v1.0.0 // indirect18 github.com/ncruces/julianday v1.0.0 // indirect
19+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
20+ github.com/sagikazarmark/locafero v0.11.0 // indirect
16 github.com/sethvargo/go-retry v0.3.0 // indirect21 github.com/sethvargo/go-retry v0.3.0 // indirect
22+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
23+ github.com/spf13/afero v1.15.0 // indirect
24+ github.com/spf13/cast v1.10.0 // indirect
25+ github.com/spf13/pflag v1.0.10 // indirect
26+ github.com/subosito/gotenv v1.6.0 // indirect
17 github.com/tetratelabs/wazero v1.10.1 // indirect27 github.com/tetratelabs/wazero v1.10.1 // indirect
18 go.uber.org/multierr v1.11.0 // indirect28 go.uber.org/multierr v1.11.0 // indirect
29+ go.yaml.in/yaml/v3 v3.0.4 // indirect
19 golang.org/x/sync v0.18.0 // indirect30 golang.org/x/sync v0.18.0 // indirect
20 golang.org/x/sys v0.38.0 // indirect31 golang.org/x/sys v0.38.0 // indirect
32+ golang.org/x/text v0.31.0 // indirect
21 )33 )
22 34
23 replace (35 replace (
M go.sum +37 -2 generated file: collapsed
A internal/infrastructure/config/defaults.go +277 -0
@@ -0,0 +1,277 @@
1+package config
2+
3+// Default configuration constants
4+const (
5+ // History defaults
6+ defaultMaxHistoryEntries = 10000 // entries
7+ defaultRetentionDays = 365 // 1 year
8+
9+ // Dmenu defaults
10+ defaultMaxHistoryItems = 20 // items
11+
12+ // Logging defaults
13+ defaultMaxLogSizeMB = 100 // MB
14+ defaultMaxBackups = 3 // backup files
15+ defaultMaxLogAgeDays = 7 // days
16+
17+ // Appearance defaults
18+ defaultFontSize = 16 // points
19+
20+ // Omnibox defaults
21+ defaultOmniboxInitialBehavior = "recent"
22+
23+ // Workspace defaults
24+ defaultPaneActivationShortcut = "ctrl+p"
25+ defaultPaneTimeoutMilliseconds = 3000
26+ defaultTabActivationShortcut = "ctrl+t"
27+ defaultTabTimeoutMilliseconds = 3000
28+ defaultTabBarPosition = "bottom"
29+ defaultPopupPlacement = "right"
30+
31+ // Workspace styling defaults
32+ // Active pane border (overlay)
33+ defaultBorderWidth = 1
34+ defaultBorderColor = "@theme_selected_bg_color"
35+
36+ // Pane mode border (Ctrl+P N - overlay)
37+ defaultPaneModeBorderWidth = 4
38+ defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
39+
40+ // Tab mode border (Ctrl+P T - overlay)
41+ defaultTabModeBorderWidth = 4
42+ defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
43+
44+ // Other styling
45+ defaultTransitionDuration = 120
46+ defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
47+)
48+
49+// getDefaultLogDir returns the default log directory, falls back to empty string on error
50+func getDefaultLogDir() string {
51+ logDir, err := GetLogDir()
52+ if err != nil {
53+ return ""
54+ }
55+ return logDir
56+}
57+
58+// DefaultConfig returns the default configuration values for dumber.
59+func DefaultConfig() *Config {
60+ return &Config{
61+ Database: DatabaseConfig{
62+ // Path is set dynamically in config.Load()
63+ },
64+ History: HistoryConfig{
65+ MaxEntries: defaultMaxHistoryEntries,
66+ RetentionPeriodDays: defaultRetentionDays, // 1 year
67+ CleanupIntervalDays: 1, // daily cleanup
68+ },
69+ SearchShortcuts: GetDefaultSearchShortcuts(),
70+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
71+ Dmenu: DmenuConfig{
72+ MaxHistoryItems: defaultMaxHistoryItems,
73+ ShowVisitCount: true,
74+ ShowLastVisited: true,
75+ HistoryPrefix: "🕒",
76+ ShortcutPrefix: "🔍",
77+ URLPrefix: "🌐",
78+ DateFormat: "2006-01-02 15:04",
79+ SortByVisitCount: true,
80+ },
81+ Logging: LoggingConfig{
82+ Level: "info",
83+ Format: "text", // text or json
84+ Filename: "", // empty means stdout
85+ MaxSize: defaultMaxLogSizeMB, // MB
86+ MaxBackups: defaultMaxBackups,
87+ MaxAge: defaultMaxLogAgeDays, // days
88+ Compress: true,
89+ LogDir: getDefaultLogDir(),
90+ EnableFileLog: true,
91+ CaptureStdout: false,
92+ CaptureStderr: false,
93+ CaptureCOutput: false, // Disabled by default to avoid performance impact
94+ CaptureConsole: false, // Disabled by default
95+ DebugFile: "debug.log",
96+ VerboseWebKit: false,
97+ },
98+ Appearance: AppearanceConfig{
99+ SansFont: "Fira Sans",
100+ SerifFont: "Fira Sans",
101+ MonospaceFont: "Fira Code",
102+ DefaultFontSize: defaultFontSize,
103+ LightPalette: ColorPalette{
104+ Background: "#fafafa",
105+ Surface: "#f4f4f5",
106+ SurfaceVariant: "#e4e4e7",
107+ Text: "#18181b",
108+ Muted: "#71717a",
109+ Accent: "#22c55e", // Green-500 - vibrant primary
110+ Border: "#d4d4d8",
111+ },
112+ DarkPalette: ColorPalette{
113+ Background: "#0a0a0b",
114+ Surface: "#18181b",
115+ SurfaceVariant: "#27272a",
116+ Text: "#fafafa",
117+ Muted: "#a1a1aa",
118+ Accent: "#4ade80", // Green-400 - vibrant primary
119+ Border: "#3f3f46",
120+ },
121+ ColorScheme: "default", // default follows system theme
122+ },
123+ VideoAcceleration: VideoAccelerationConfig{
124+ EnableVAAPI: true,
125+ AutoDetectGPU: true,
126+ VAAPIDriverName: "", // TODO: implement in Step 18 - will be auto-detected
127+ EnableAllDrivers: true,
128+ LegacyVAAPI: false,
129+ },
130+ CodecPreferences: CodecConfig{
131+ PreferredCodecs: "av1,h264", // AV1 first, but allow VP9 fallback for higher resolutions
132+ ForceAV1: false, // Use smart AV1 negotiation instead of forcing
133+ BlockVP9: false, // Allow VP9 for higher resolutions
134+ BlockVP8: false, // Allow VP8 fallback for ad segments (lower priority)
135+ AV1HardwareOnly: false, // Allow software AV1 fallback
136+ DisableVP9Hardware: false, // Allow VP9 hardware for high res content
137+ VideoBufferSizeMB: 64, // Larger buffer for bursty Twitch/AV1 segments
138+ QueueBufferTimeSec: 20, // More buffering time for smooth playback
139+ CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", // WebKitGTK default (same as Epiphany)
140+ AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
141+ DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
142+ },
143+ Debug: DebugConfig{
144+ EnableWebKitDebug: false,
145+ WebKitDebugCategories: "Network:preconnectTo,ContentFilters",
146+ EnableFilteringDebug: false,
147+ EnableWebViewDebug: false,
148+ LogWebKitCrashes: true, // Always log crashes
149+ EnableScriptDebug: false,
150+ EnableGeneralDebug: false,
151+ EnableWorkspaceDebug: false,
152+ EnableFocusDebug: false,
153+ EnableCSSDebug: false,
154+ EnableFocusMetrics: false,
155+ EnablePaneCloseDebug: false,
156+ },
157+ RenderingMode: RenderingModeGPU,
158+ UseDomZoom: false,
159+ DefaultZoom: 1.2, // 120% default zoom for better readability
160+ Workspace: WorkspaceConfig{
161+ PaneMode: PaneModeConfig{
162+ ActivationShortcut: defaultPaneActivationShortcut,
163+ TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
164+ Actions: map[string][]string{
165+ "split-right": {"arrowright", "r"},
166+ "split-left": {"arrowleft", "l"},
167+ "split-up": {"arrowup", "u"},
168+ "split-down": {"arrowdown", "d"},
169+ "close-pane": {"x"},
170+ "confirm": {"enter"},
171+ "cancel": {"escape"},
172+ },
173+ },
174+ TabMode: TabModeConfig{
175+ ActivationShortcut: defaultTabActivationShortcut,
176+ TimeoutMilliseconds: defaultTabTimeoutMilliseconds,
177+ Actions: map[string][]string{
178+ "new-tab": {"n", "c"},
179+ "close-tab": {"x"},
180+ "next-tab": {"l", "tab"},
181+ "previous-tab": {"h", "shift+tab"},
182+ "rename-tab": {"r"},
183+ "confirm": {"enter"},
184+ "cancel": {"escape"},
185+ },
186+ },
187+ Tabs: TabKeyConfig{
188+ NewTab: "ctrl+t",
189+ CloseTab: "ctrl+w",
190+ NextTab: "ctrl+tab",
191+ PreviousTab: "ctrl+shift+tab",
192+ },
193+ TabBarPosition: defaultTabBarPosition,
194+ Popups: PopupBehaviorConfig{
195+ Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
196+ Placement: defaultPopupPlacement,
197+ OpenInNewPane: true,
198+ FollowPaneContext: true,
199+ BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
200+ EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
201+ OAuthAutoClose: true, // Auto-close OAuth popups on success
202+ },
203+ Styling: WorkspaceStylingConfig{
204+ BorderWidth: defaultBorderWidth,
205+ BorderColor: defaultBorderColor,
206+ PaneModeBorderWidth: defaultPaneModeBorderWidth,
207+ PaneModeBorderColor: defaultPaneModeBorderColor,
208+ TabModeBorderWidth: defaultTabModeBorderWidth,
209+ TabModeBorderColor: defaultTabModeBorderColor,
210+ TransitionDuration: defaultTransitionDuration,
211+ UIScale: defaultUIScale,
212+ },
213+ },
214+ ContentFiltering: ContentFilteringConfig{
215+ Enabled: true, // Ad blocking enabled by default
216+ // Note: Whitelist is now managed via database (content_whitelist table)
217+ FilterLists: []string{
218+ // Core blocking
219+ "https://easylist.to/easylist/easylist.txt", // Ads
220+ "https://easylist.to/easylist/easyprivacy.txt", // Tracking
221+ // uBlock extras
222+ "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", // uBO optimizations
223+ "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt", // Cookie banners, popups
224+ "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/quick-fixes.txt", // Site fixes
225+ },
226+ },
227+ Omnibox: OmniboxConfig{
228+ InitialBehavior: defaultOmniboxInitialBehavior,
229+ },
230+ }
231+}
232+
233+// GetDefaultSearchShortcuts returns the default search shortcuts.
234+func GetDefaultSearchShortcuts() map[string]SearchShortcut {
235+ return map[string]SearchShortcut{
236+ "ddg": {
237+ URL: "https://duckduckgo.com/?q=%s",
238+ Description: "DuckDuckGo search",
239+ },
240+ "g": {
241+ URL: "https://www.google.com/search?q=%s",
242+ Description: "Google search",
243+ },
244+ "gh": {
245+ URL: "https://github.com/search?q=%s",
246+ Description: "GitHub search",
247+ },
248+ "go": {
249+ URL: "https://pkg.go.dev/search?q=%s",
250+ Description: "Go package search",
251+ },
252+ "mdn": {
253+ URL: "https://developer.mozilla.org/en-US/search?q=%s",
254+ Description: "MDN Web Docs search",
255+ },
256+ "npm": {
257+ URL: "https://www.npmjs.com/search?q=%s",
258+ Description: "npm package search",
259+ },
260+ "r": {
261+ URL: "https://www.reddit.com/search?q=%s",
262+ Description: "Reddit search",
263+ },
264+ "so": {
265+ URL: "https://stackoverflow.com/search?q=%s",
266+ Description: "Stack Overflow search",
267+ },
268+ "w": {
269+ URL: "https://en.wikipedia.org/wiki/%s",
270+ Description: "Wikipedia search",
271+ },
272+ "yt": {
273+ URL: "https://www.youtube.com/results?search_query=%s",
274+ Description: "YouTube search",
275+ },
276+ }
277+}
@@ -0,0 +1,277 @@
1+package config
2+
3+// Default configuration constants
4+const (
5+ // History defaults
6+ defaultMaxHistoryEntries = 10000 // entries
7+ defaultRetentionDays = 365 // 1 year
8+
9+ // Dmenu defaults
10+ defaultMaxHistoryItems = 20 // items
11+
12+ // Logging defaults
13+ defaultMaxLogSizeMB = 100 // MB
14+ defaultMaxBackups = 3 // backup files
15+ defaultMaxLogAgeDays = 7 // days
16+
17+ // Appearance defaults
18+ defaultFontSize = 16 // points
19+
20+ // Omnibox defaults
21+ defaultOmniboxInitialBehavior = "recent"
22+
23+ // Workspace defaults
24+ defaultPaneActivationShortcut = "ctrl+p"
25+ defaultPaneTimeoutMilliseconds = 3000
26+ defaultTabActivationShortcut = "ctrl+t"
27+ defaultTabTimeoutMilliseconds = 3000
28+ defaultTabBarPosition = "bottom"
29+ defaultPopupPlacement = "right"
30+
31+ // Workspace styling defaults
32+ // Active pane border (overlay)
33+ defaultBorderWidth = 1
34+ defaultBorderColor = "@theme_selected_bg_color"
35+
36+ // Pane mode border (Ctrl+P N - overlay)
37+ defaultPaneModeBorderWidth = 4
38+ defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
39+
40+ // Tab mode border (Ctrl+P T - overlay)
41+ defaultTabModeBorderWidth = 4
42+ defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
43+
44+ // Other styling
45+ defaultTransitionDuration = 120
46+ defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
47+)
48+
49+// getDefaultLogDir returns the default log directory, falls back to empty string on error
50+func getDefaultLogDir() string {
51+ logDir, err := GetLogDir()
52+ if err != nil {
53+ return ""
54+ }
55+ return logDir
56+}
57+
58+// DefaultConfig returns the default configuration values for dumber.
59+func DefaultConfig() *Config {
60+ return &Config{
61+ Database: DatabaseConfig{
62+ // Path is set dynamically in config.Load()
63+ },
64+ History: HistoryConfig{
65+ MaxEntries: defaultMaxHistoryEntries,
66+ RetentionPeriodDays: defaultRetentionDays, // 1 year
67+ CleanupIntervalDays: 1, // daily cleanup
68+ },
69+ SearchShortcuts: GetDefaultSearchShortcuts(),
70+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
71+ Dmenu: DmenuConfig{
72+ MaxHistoryItems: defaultMaxHistoryItems,
73+ ShowVisitCount: true,
74+ ShowLastVisited: true,
75+ HistoryPrefix: "🕒",
76+ ShortcutPrefix: "🔍",
77+ URLPrefix: "🌐",
78+ DateFormat: "2006-01-02 15:04",
79+ SortByVisitCount: true,
80+ },
81+ Logging: LoggingConfig{
82+ Level: "info",
83+ Format: "text", // text or json
84+ Filename: "", // empty means stdout
85+ MaxSize: defaultMaxLogSizeMB, // MB
86+ MaxBackups: defaultMaxBackups,
87+ MaxAge: defaultMaxLogAgeDays, // days
88+ Compress: true,
89+ LogDir: getDefaultLogDir(),
90+ EnableFileLog: true,
91+ CaptureStdout: false,
92+ CaptureStderr: false,
93+ CaptureCOutput: false, // Disabled by default to avoid performance impact
94+ CaptureConsole: false, // Disabled by default
95+ DebugFile: "debug.log",
96+ VerboseWebKit: false,
97+ },
98+ Appearance: AppearanceConfig{
99+ SansFont: "Fira Sans",
100+ SerifFont: "Fira Sans",
101+ MonospaceFont: "Fira Code",
102+ DefaultFontSize: defaultFontSize,
103+ LightPalette: ColorPalette{
104+ Background: "#fafafa",
105+ Surface: "#f4f4f5",
106+ SurfaceVariant: "#e4e4e7",
107+ Text: "#18181b",
108+ Muted: "#71717a",
109+ Accent: "#22c55e", // Green-500 - vibrant primary
110+ Border: "#d4d4d8",
111+ },
112+ DarkPalette: ColorPalette{
113+ Background: "#0a0a0b",
114+ Surface: "#18181b",
115+ SurfaceVariant: "#27272a",
116+ Text: "#fafafa",
117+ Muted: "#a1a1aa",
118+ Accent: "#4ade80", // Green-400 - vibrant primary
119+ Border: "#3f3f46",
120+ },
121+ ColorScheme: "default", // default follows system theme
122+ },
123+ VideoAcceleration: VideoAccelerationConfig{
124+ EnableVAAPI: true,
125+ AutoDetectGPU: true,
126+ VAAPIDriverName: "", // TODO: implement in Step 18 - will be auto-detected
127+ EnableAllDrivers: true,
128+ LegacyVAAPI: false,
129+ },
130+ CodecPreferences: CodecConfig{
131+ PreferredCodecs: "av1,h264", // AV1 first, but allow VP9 fallback for higher resolutions
132+ ForceAV1: false, // Use smart AV1 negotiation instead of forcing
133+ BlockVP9: false, // Allow VP9 for higher resolutions
134+ BlockVP8: false, // Allow VP8 fallback for ad segments (lower priority)
135+ AV1HardwareOnly: false, // Allow software AV1 fallback
136+ DisableVP9Hardware: false, // Allow VP9 hardware for high res content
137+ VideoBufferSizeMB: 64, // Larger buffer for bursty Twitch/AV1 segments
138+ QueueBufferTimeSec: 20, // More buffering time for smooth playback
139+ CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", // WebKitGTK default (same as Epiphany)
140+ AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
141+ DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
142+ },
143+ Debug: DebugConfig{
144+ EnableWebKitDebug: false,
145+ WebKitDebugCategories: "Network:preconnectTo,ContentFilters",
146+ EnableFilteringDebug: false,
147+ EnableWebViewDebug: false,
148+ LogWebKitCrashes: true, // Always log crashes
149+ EnableScriptDebug: false,
150+ EnableGeneralDebug: false,
151+ EnableWorkspaceDebug: false,
152+ EnableFocusDebug: false,
153+ EnableCSSDebug: false,
154+ EnableFocusMetrics: false,
155+ EnablePaneCloseDebug: false,
156+ },
157+ RenderingMode: RenderingModeGPU,
158+ UseDomZoom: false,
159+ DefaultZoom: 1.2, // 120% default zoom for better readability
160+ Workspace: WorkspaceConfig{
161+ PaneMode: PaneModeConfig{
162+ ActivationShortcut: defaultPaneActivationShortcut,
163+ TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
164+ Actions: map[string][]string{
165+ "split-right": {"arrowright", "r"},
166+ "split-left": {"arrowleft", "l"},
167+ "split-up": {"arrowup", "u"},
168+ "split-down": {"arrowdown", "d"},
169+ "close-pane": {"x"},
170+ "confirm": {"enter"},
171+ "cancel": {"escape"},
172+ },
173+ },
174+ TabMode: TabModeConfig{
175+ ActivationShortcut: defaultTabActivationShortcut,
176+ TimeoutMilliseconds: defaultTabTimeoutMilliseconds,
177+ Actions: map[string][]string{
178+ "new-tab": {"n", "c"},
179+ "close-tab": {"x"},
180+ "next-tab": {"l", "tab"},
181+ "previous-tab": {"h", "shift+tab"},
182+ "rename-tab": {"r"},
183+ "confirm": {"enter"},
184+ "cancel": {"escape"},
185+ },
186+ },
187+ Tabs: TabKeyConfig{
188+ NewTab: "ctrl+t",
189+ CloseTab: "ctrl+w",
190+ NextTab: "ctrl+tab",
191+ PreviousTab: "ctrl+shift+tab",
192+ },
193+ TabBarPosition: defaultTabBarPosition,
194+ Popups: PopupBehaviorConfig{
195+ Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
196+ Placement: defaultPopupPlacement,
197+ OpenInNewPane: true,
198+ FollowPaneContext: true,
199+ BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
200+ EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
201+ OAuthAutoClose: true, // Auto-close OAuth popups on success
202+ },
203+ Styling: WorkspaceStylingConfig{
204+ BorderWidth: defaultBorderWidth,
205+ BorderColor: defaultBorderColor,
206+ PaneModeBorderWidth: defaultPaneModeBorderWidth,
207+ PaneModeBorderColor: defaultPaneModeBorderColor,
208+ TabModeBorderWidth: defaultTabModeBorderWidth,
209+ TabModeBorderColor: defaultTabModeBorderColor,
210+ TransitionDuration: defaultTransitionDuration,
211+ UIScale: defaultUIScale,
212+ },
213+ },
214+ ContentFiltering: ContentFilteringConfig{
215+ Enabled: true, // Ad blocking enabled by default
216+ // Note: Whitelist is now managed via database (content_whitelist table)
217+ FilterLists: []string{
218+ // Core blocking
219+ "https://easylist.to/easylist/easylist.txt", // Ads
220+ "https://easylist.to/easylist/easyprivacy.txt", // Tracking
221+ // uBlock extras
222+ "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", // uBO optimizations
223+ "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt", // Cookie banners, popups
224+ "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/quick-fixes.txt", // Site fixes
225+ },
226+ },
227+ Omnibox: OmniboxConfig{
228+ InitialBehavior: defaultOmniboxInitialBehavior,
229+ },
230+ }
231+}
232+
233+// GetDefaultSearchShortcuts returns the default search shortcuts.
234+func GetDefaultSearchShortcuts() map[string]SearchShortcut {
235+ return map[string]SearchShortcut{
236+ "ddg": {
237+ URL: "https://duckduckgo.com/?q=%s",
238+ Description: "DuckDuckGo search",
239+ },
240+ "g": {
241+ URL: "https://www.google.com/search?q=%s",
242+ Description: "Google search",
243+ },
244+ "gh": {
245+ URL: "https://github.com/search?q=%s",
246+ Description: "GitHub search",
247+ },
248+ "go": {
249+ URL: "https://pkg.go.dev/search?q=%s",
250+ Description: "Go package search",
251+ },
252+ "mdn": {
253+ URL: "https://developer.mozilla.org/en-US/search?q=%s",
254+ Description: "MDN Web Docs search",
255+ },
256+ "npm": {
257+ URL: "https://www.npmjs.com/search?q=%s",
258+ Description: "npm package search",
259+ },
260+ "r": {
261+ URL: "https://www.reddit.com/search?q=%s",
262+ Description: "Reddit search",
263+ },
264+ "so": {
265+ URL: "https://stackoverflow.com/search?q=%s",
266+ Description: "Stack Overflow search",
267+ },
268+ "w": {
269+ URL: "https://en.wikipedia.org/wiki/%s",
270+ Description: "Wikipedia search",
271+ },
272+ "yt": {
273+ URL: "https://www.youtube.com/results?search_query=%s",
274+ Description: "YouTube search",
275+ },
276+ }
277+}
A internal/infrastructure/config/loader.go +407 -0
@@ -0,0 +1,407 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+
11+ "github.com/spf13/viper"
12+)
13+
14+// Manager handles configuration loading, watching, and reloading.
15+type Manager struct {
16+ config *Config
17+ viper *viper.Viper
18+ mu sync.RWMutex
19+ callbacks []func(*Config)
20+ watching bool
21+}
22+
23+// NewManager creates a new configuration manager.
24+func NewManager() (*Manager, error) {
25+ v := viper.New()
26+
27+ // Configure Viper for TOML as default format
28+ v.SetConfigName("config") // Name without extension
29+ v.SetConfigType("toml") // TOML as default format
30+
31+ // Add config paths
32+ configDir, err := GetConfigDir()
33+ if err != nil {
34+ return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
35+ }
36+ v.AddConfigPath(configDir)
37+ v.AddConfigPath(".") // Current directory for development
38+
39+ // Set up environment variable support
40+ v.SetEnvPrefix("DUMB_BROWSER")
41+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
42+ v.AutomaticEnv()
43+
44+ // Note: Most environment variables are handled automatically via AutomaticEnv()
45+ // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
46+ // The explicit bindings below are only for special cases with different naming patterns.
47+
48+ // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
49+ if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
50+ return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
51+ }
52+ if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
53+ return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
54+ }
55+ if err := v.BindEnv("default_zoom", "DUMBER_DEFAULT_ZOOM"); err != nil {
56+ return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_ZOOM: %w", err)
57+ }
58+
59+ // Video acceleration environment variable bindings
60+ // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
61+ videoAccelEnvBindings := map[string]string{
62+ "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
63+ "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
64+ "video_acceleration.vaapi_driver_name": "LIBVA_DRIVER_NAME",
65+ "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
66+ "video_acceleration.legacy_vaapi": "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
67+ }
68+
69+ for key, env := range videoAccelEnvBindings {
70+ if err := v.BindEnv(key, env); err != nil {
71+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
72+ }
73+ }
74+
75+ // Codec preferences environment variable bindings
76+ // These use DUMBER_* prefix for shorter, more convenient env var names
77+ codecEnvBindings := map[string]string{
78+ "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
79+ "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
80+ "codec_preferences.block_vp9": "DUMBER_BLOCK_VP9",
81+ "codec_preferences.block_vp8": "DUMBER_BLOCK_VP8",
82+ "codec_preferences.av1_hardware_only": "DUMBER_AV1_HW_ONLY",
83+ "codec_preferences.disable_vp9_hardware": "DUMBER_DISABLE_VP9_HW",
84+ "codec_preferences.video_buffer_size_mb": "DUMBER_VIDEO_BUFFER_MB",
85+ "codec_preferences.queue_buffer_time_sec": "DUMBER_QUEUE_BUFFER_SEC",
86+ "codec_preferences.custom_user_agent": "DUMBER_CUSTOM_UA",
87+ "codec_preferences.av1_max_resolution": "DUMBER_AV1_MAX_RES",
88+ "codec_preferences.disable_twitch_codec_control": "DUMBER_DISABLE_TWITCH_CODEC",
89+ }
90+
91+ for key, env := range codecEnvBindings {
92+ if err := v.BindEnv(key, env); err != nil {
93+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
94+ }
95+ }
96+
97+ // Logging environment variable bindings
98+ if err := v.BindEnv("logging.level", "DUMBER_LOG_LEVEL"); err != nil {
99+ return nil, fmt.Errorf("failed to bind DUMBER_LOG_LEVEL: %w", err)
100+ }
101+ if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
102+ return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
103+ }
104+
105+ return &Manager{
106+ viper: v,
107+ callbacks: make([]func(*Config), 0),
108+ }, nil
109+}
110+
111+// Load loads the configuration from file and environment variables.
112+func (m *Manager) Load() error {
113+ m.mu.Lock()
114+ defer m.mu.Unlock()
115+
116+ // Ensure directories exist
117+ if err := EnsureDirectories(); err != nil {
118+ return fmt.Errorf("failed to ensure directories: %w", err)
119+ }
120+
121+ // Set defaults
122+ m.setDefaults()
123+
124+ // Read config file if it exists
125+ if err := m.viper.ReadInConfig(); err != nil {
126+ var configFileNotFoundError viper.ConfigFileNotFoundError
127+ if errors.As(err, &configFileNotFoundError) {
128+ // Config file not found, create default one
129+ if err := m.createDefaultConfig(); err != nil {
130+ configDir, _ := GetConfigDir()
131+ return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err)
132+ }
133+ // Re-read the newly created config file
134+ if err := m.viper.ReadInConfig(); err != nil {
135+ return fmt.Errorf("failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format", err)
136+ }
137+ } else {
138+ configFile := m.viper.ConfigFileUsed()
139+ if configFile == "" {
140+ configDir, _ := GetConfigDir()
141+ configFile = filepath.Join(configDir, "config.toml")
142+ }
143+ return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
144+ }
145+ }
146+
147+ // Unmarshal into config struct
148+ config := &Config{}
149+ if err := m.viper.Unmarshal(config); err != nil {
150+ configFile := m.viper.ConfigFileUsed()
151+ return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err)
152+ }
153+
154+ // Set database path if not specified
155+ if config.Database.Path == "" {
156+ dbPath, err := GetDatabaseFile()
157+ if err != nil {
158+ return fmt.Errorf("failed to get database path: %w", err)
159+ }
160+ config.Database.Path = dbPath
161+ }
162+
163+ // Normalize/validate rendering mode using switch statement
164+ switch strings.ToLower(string(config.RenderingMode)) {
165+ case "", string(RenderingModeAuto):
166+ config.RenderingMode = RenderingModeAuto
167+ case string(RenderingModeGPU):
168+ config.RenderingMode = RenderingModeGPU
169+ case string(RenderingModeCPU):
170+ config.RenderingMode = RenderingModeCPU
171+ default:
172+ config.RenderingMode = RenderingModeAuto
173+ }
174+
175+ // TODO: implement in Step 18 - Auto-detect GPU if enabled and driver name is not set
176+ // if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
177+ // gpuInfo := gpu.DetectGPU()
178+ // if gpuInfo.SupportsVAAPI() {
179+ // config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
180+ // }
181+ // }
182+
183+ // TODO: implement in Step 18 - Validate and configure codec preferences based on GPU
184+ // config = m.validateAndConfigureCodecPreferences(config)
185+
186+ // Validate ColorScheme setting using switch statement
187+ switch config.Appearance.ColorScheme {
188+ case "prefer-dark", "prefer-light", ThemeDefault:
189+ // Valid values - no changes needed
190+ case "":
191+ // Empty string is treated the same as "default"
192+ config.Appearance.ColorScheme = ThemeDefault
193+ default:
194+ // Invalid value - reset to default
195+ config.Appearance.ColorScheme = ThemeDefault
196+ }
197+
198+ // Validate all config values
199+ if err := validateConfig(config); err != nil {
200+ return fmt.Errorf("configuration validation failed: %w", err)
201+ }
202+
203+ m.config = config
204+ return nil
205+}
206+
207+// Get returns the current configuration (thread-safe).
208+func (m *Manager) Get() *Config {
209+ m.mu.RLock()
210+ defer m.mu.RUnlock()
211+
212+ // Return a copy to prevent external modification
213+ configCopy := *m.config
214+ return &configCopy
215+}
216+
217+// GetConfigFile returns the path to the configuration file being used.
218+func (m *Manager) GetConfigFile() string {
219+ return m.viper.ConfigFileUsed()
220+}
221+
222+// createDefaultConfig creates a default configuration file.
223+func (m *Manager) createDefaultConfig() error {
224+ configFile, err := GetConfigFile()
225+ if err != nil {
226+ return err
227+ }
228+
229+ // Create config directory if it doesn't exist
230+ if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
231+ return err
232+ }
233+
234+ // Ensure TOML format and write config
235+ m.viper.SetConfigType("toml")
236+ if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
237+ return fmt.Errorf("failed to write config file: %w", err)
238+ }
239+
240+ fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
241+
242+ return nil
243+}
244+
245+// setDefaults sets default configuration values in Viper.
246+func (m *Manager) setDefaults() {
247+ defaults := DefaultConfig()
248+
249+ // Note: Database.Path is set dynamically in Load(), no defaults needed
250+
251+ // History defaults
252+ m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
253+ m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
254+ m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
255+
256+ // Search shortcuts defaults
257+ m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
258+ m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
259+
260+ // Dmenu defaults
261+ m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
262+ m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
263+ m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
264+ m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
265+ m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
266+ m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
267+ m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
268+ m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
269+
270+ // Logging defaults
271+ m.viper.SetDefault("logging.level", defaults.Logging.Level)
272+ m.viper.SetDefault("logging.format", defaults.Logging.Format)
273+ m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
274+ m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
275+ m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
276+ m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
277+ m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
278+ m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
279+ m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
280+ m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout)
281+ m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr)
282+ m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput)
283+ m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
284+ m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
285+ m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
286+
287+ // Debug defaults
288+ m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
289+ m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
290+ m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
291+ m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
292+ m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
293+ m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
294+ m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
295+ m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
296+ m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
297+ m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
298+ m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
299+ m.viper.SetDefault("debug.enable_pane_close_debug", defaults.Debug.EnablePaneCloseDebug)
300+
301+ // Appearance defaults
302+ m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
303+ m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
304+ m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
305+ m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
306+ m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
307+ m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
308+ m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
309+
310+ // Video acceleration defaults
311+ m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
312+ m.viper.SetDefault("video_acceleration.auto_detect_gpu", defaults.VideoAcceleration.AutoDetectGPU)
313+ m.viper.SetDefault("video_acceleration.vaapi_driver_name", defaults.VideoAcceleration.VAAPIDriverName)
314+ m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
315+ m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
316+
317+ // Codec preferences defaults
318+ m.viper.SetDefault("codec_preferences.preferred_codecs", defaults.CodecPreferences.PreferredCodecs)
319+ m.viper.SetDefault("codec_preferences.force_av1", defaults.CodecPreferences.ForceAV1)
320+ m.viper.SetDefault("codec_preferences.block_vp9", defaults.CodecPreferences.BlockVP9)
321+ m.viper.SetDefault("codec_preferences.block_vp8", defaults.CodecPreferences.BlockVP8)
322+ m.viper.SetDefault("codec_preferences.av1_hardware_only", defaults.CodecPreferences.AV1HardwareOnly)
323+ m.viper.SetDefault("codec_preferences.disable_vp9_hardware", defaults.CodecPreferences.DisableVP9Hardware)
324+ m.viper.SetDefault("codec_preferences.video_buffer_size_mb", defaults.CodecPreferences.VideoBufferSizeMB)
325+ m.viper.SetDefault("codec_preferences.queue_buffer_time_sec", defaults.CodecPreferences.QueueBufferTimeSec)
326+ m.viper.SetDefault("codec_preferences.custom_user_agent", defaults.CodecPreferences.CustomUserAgent)
327+ m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
328+ m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
329+
330+ // Rendering defaults
331+ m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
332+ m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
333+ m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
334+
335+ // Workspace defaults
336+ m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
337+ m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
338+ m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
339+ m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
340+ m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
341+ m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
342+ m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
343+ m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
344+ m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
345+ m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
346+ m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
347+ m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
348+ m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
349+ m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
350+ m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
351+ m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
352+ m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
353+ m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
354+ m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
355+ m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
356+ m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)
357+ m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
358+ m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
359+ m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
360+ m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
361+ m.viper.SetDefault("workspace.styling.ui_scale", defaults.Workspace.Styling.UIScale)
362+
363+ // Content filtering
364+ m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
365+ m.viper.SetDefault("content_filtering.filter_lists", defaults.ContentFiltering.FilterLists)
366+
367+ // Omnibox defaults
368+ m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
369+}
370+
371+// New returns a new default configuration instance.
372+// This is a convenience function for getting default config without the full manager.
373+func New() *Config {
374+ return DefaultConfig()
375+}
376+
377+// Global configuration manager instance
378+var globalManager *Manager
379+var globalManagerOnce sync.Once
380+
381+// Init initializes the global configuration manager.
382+func Init() error {
383+ var err error
384+ globalManagerOnce.Do(func() {
385+ globalManager, err = NewManager()
386+ if err != nil {
387+ return
388+ }
389+ err = globalManager.Load()
390+ })
391+ return err
392+}
393+
394+// Get returns the global configuration.
395+func Get() *Config {
396+ if globalManager == nil {
397+ // Return defaults if not initialized
398+ return DefaultConfig()
399+ }
400+ return globalManager.Get()
401+}
402+
403+// GetManager returns the global configuration manager.
404+// This is useful for accessing watcher functionality.
405+func GetManager() *Manager {
406+ return globalManager
407+}
@@ -0,0 +1,407 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+
11+ "github.com/spf13/viper"
12+)
13+
14+// Manager handles configuration loading, watching, and reloading.
15+type Manager struct {
16+ config *Config
17+ viper *viper.Viper
18+ mu sync.RWMutex
19+ callbacks []func(*Config)
20+ watching bool
21+}
22+
23+// NewManager creates a new configuration manager.
24+func NewManager() (*Manager, error) {
25+ v := viper.New()
26+
27+ // Configure Viper for TOML as default format
28+ v.SetConfigName("config") // Name without extension
29+ v.SetConfigType("toml") // TOML as default format
30+
31+ // Add config paths
32+ configDir, err := GetConfigDir()
33+ if err != nil {
34+ return nil, fmt.Errorf("failed to determine config directory: %w\nCheck XDG_CONFIG_HOME environment variable or HOME directory", err)
35+ }
36+ v.AddConfigPath(configDir)
37+ v.AddConfigPath(".") // Current directory for development
38+
39+ // Set up environment variable support
40+ v.SetEnvPrefix("DUMB_BROWSER")
41+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
42+ v.AutomaticEnv()
43+
44+ // Note: Most environment variables are handled automatically via AutomaticEnv()
45+ // with the DUMB_BROWSER_ prefix (e.g., DUMB_BROWSER_DATABASE_PATH).
46+ // The explicit bindings below are only for special cases with different naming patterns.
47+
48+ // Explicit bindings for DUMBER_* prefix (different from DUMB_BROWSER_)
49+ if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
50+ return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
51+ }
52+ if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
53+ return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
54+ }
55+ if err := v.BindEnv("default_zoom", "DUMBER_DEFAULT_ZOOM"); err != nil {
56+ return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_ZOOM: %w", err)
57+ }
58+
59+ // Video acceleration environment variable bindings
60+ // These use system-standard env vars (LIBVA_*, GST_*, WEBKIT_*) and DUMBER_* prefix
61+ videoAccelEnvBindings := map[string]string{
62+ "video_acceleration.enable_vaapi": "DUMBER_VIDEO_ACCELERATION_ENABLE",
63+ "video_acceleration.auto_detect_gpu": "DUMBER_VIDEO_AUTO_DETECT",
64+ "video_acceleration.vaapi_driver_name": "LIBVA_DRIVER_NAME",
65+ "video_acceleration.enable_all_drivers": "GST_VAAPI_ALL_DRIVERS",
66+ "video_acceleration.legacy_vaapi": "WEBKIT_GST_ENABLE_LEGACY_VAAPI",
67+ }
68+
69+ for key, env := range videoAccelEnvBindings {
70+ if err := v.BindEnv(key, env); err != nil {
71+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
72+ }
73+ }
74+
75+ // Codec preferences environment variable bindings
76+ // These use DUMBER_* prefix for shorter, more convenient env var names
77+ codecEnvBindings := map[string]string{
78+ "codec_preferences.preferred_codecs": "DUMBER_PREFERRED_CODECS",
79+ "codec_preferences.force_av1": "DUMBER_FORCE_AV1",
80+ "codec_preferences.block_vp9": "DUMBER_BLOCK_VP9",
81+ "codec_preferences.block_vp8": "DUMBER_BLOCK_VP8",
82+ "codec_preferences.av1_hardware_only": "DUMBER_AV1_HW_ONLY",
83+ "codec_preferences.disable_vp9_hardware": "DUMBER_DISABLE_VP9_HW",
84+ "codec_preferences.video_buffer_size_mb": "DUMBER_VIDEO_BUFFER_MB",
85+ "codec_preferences.queue_buffer_time_sec": "DUMBER_QUEUE_BUFFER_SEC",
86+ "codec_preferences.custom_user_agent": "DUMBER_CUSTOM_UA",
87+ "codec_preferences.av1_max_resolution": "DUMBER_AV1_MAX_RES",
88+ "codec_preferences.disable_twitch_codec_control": "DUMBER_DISABLE_TWITCH_CODEC",
89+ }
90+
91+ for key, env := range codecEnvBindings {
92+ if err := v.BindEnv(key, env); err != nil {
93+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
94+ }
95+ }
96+
97+ // Logging environment variable bindings
98+ if err := v.BindEnv("logging.level", "DUMBER_LOG_LEVEL"); err != nil {
99+ return nil, fmt.Errorf("failed to bind DUMBER_LOG_LEVEL: %w", err)
100+ }
101+ if err := v.BindEnv("logging.format", "DUMBER_LOG_FORMAT"); err != nil {
102+ return nil, fmt.Errorf("failed to bind DUMBER_LOG_FORMAT: %w", err)
103+ }
104+
105+ return &Manager{
106+ viper: v,
107+ callbacks: make([]func(*Config), 0),
108+ }, nil
109+}
110+
111+// Load loads the configuration from file and environment variables.
112+func (m *Manager) Load() error {
113+ m.mu.Lock()
114+ defer m.mu.Unlock()
115+
116+ // Ensure directories exist
117+ if err := EnsureDirectories(); err != nil {
118+ return fmt.Errorf("failed to ensure directories: %w", err)
119+ }
120+
121+ // Set defaults
122+ m.setDefaults()
123+
124+ // Read config file if it exists
125+ if err := m.viper.ReadInConfig(); err != nil {
126+ var configFileNotFoundError viper.ConfigFileNotFoundError
127+ if errors.As(err, &configFileNotFoundError) {
128+ // Config file not found, create default one
129+ if err := m.createDefaultConfig(); err != nil {
130+ configDir, _ := GetConfigDir()
131+ return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err)
132+ }
133+ // Re-read the newly created config file
134+ if err := m.viper.ReadInConfig(); err != nil {
135+ return fmt.Errorf("failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format", err)
136+ }
137+ } else {
138+ configFile := m.viper.ConfigFileUsed()
139+ if configFile == "" {
140+ configDir, _ := GetConfigDir()
141+ configFile = filepath.Join(configDir, "config.toml")
142+ }
143+ return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
144+ }
145+ }
146+
147+ // Unmarshal into config struct
148+ config := &Config{}
149+ if err := m.viper.Unmarshal(config); err != nil {
150+ configFile := m.viper.ConfigFileUsed()
151+ return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err)
152+ }
153+
154+ // Set database path if not specified
155+ if config.Database.Path == "" {
156+ dbPath, err := GetDatabaseFile()
157+ if err != nil {
158+ return fmt.Errorf("failed to get database path: %w", err)
159+ }
160+ config.Database.Path = dbPath
161+ }
162+
163+ // Normalize/validate rendering mode using switch statement
164+ switch strings.ToLower(string(config.RenderingMode)) {
165+ case "", string(RenderingModeAuto):
166+ config.RenderingMode = RenderingModeAuto
167+ case string(RenderingModeGPU):
168+ config.RenderingMode = RenderingModeGPU
169+ case string(RenderingModeCPU):
170+ config.RenderingMode = RenderingModeCPU
171+ default:
172+ config.RenderingMode = RenderingModeAuto
173+ }
174+
175+ // TODO: implement in Step 18 - Auto-detect GPU if enabled and driver name is not set
176+ // if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
177+ // gpuInfo := gpu.DetectGPU()
178+ // if gpuInfo.SupportsVAAPI() {
179+ // config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
180+ // }
181+ // }
182+
183+ // TODO: implement in Step 18 - Validate and configure codec preferences based on GPU
184+ // config = m.validateAndConfigureCodecPreferences(config)
185+
186+ // Validate ColorScheme setting using switch statement
187+ switch config.Appearance.ColorScheme {
188+ case "prefer-dark", "prefer-light", ThemeDefault:
189+ // Valid values - no changes needed
190+ case "":
191+ // Empty string is treated the same as "default"
192+ config.Appearance.ColorScheme = ThemeDefault
193+ default:
194+ // Invalid value - reset to default
195+ config.Appearance.ColorScheme = ThemeDefault
196+ }
197+
198+ // Validate all config values
199+ if err := validateConfig(config); err != nil {
200+ return fmt.Errorf("configuration validation failed: %w", err)
201+ }
202+
203+ m.config = config
204+ return nil
205+}
206+
207+// Get returns the current configuration (thread-safe).
208+func (m *Manager) Get() *Config {
209+ m.mu.RLock()
210+ defer m.mu.RUnlock()
211+
212+ // Return a copy to prevent external modification
213+ configCopy := *m.config
214+ return &configCopy
215+}
216+
217+// GetConfigFile returns the path to the configuration file being used.
218+func (m *Manager) GetConfigFile() string {
219+ return m.viper.ConfigFileUsed()
220+}
221+
222+// createDefaultConfig creates a default configuration file.
223+func (m *Manager) createDefaultConfig() error {
224+ configFile, err := GetConfigFile()
225+ if err != nil {
226+ return err
227+ }
228+
229+ // Create config directory if it doesn't exist
230+ if err := os.MkdirAll(filepath.Dir(configFile), dirPerm); err != nil {
231+ return err
232+ }
233+
234+ // Ensure TOML format and write config
235+ m.viper.SetConfigType("toml")
236+ if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
237+ return fmt.Errorf("failed to write config file: %w", err)
238+ }
239+
240+ fmt.Printf("Created default configuration file: %s (TOML format)\n", configFile)
241+
242+ return nil
243+}
244+
245+// setDefaults sets default configuration values in Viper.
246+func (m *Manager) setDefaults() {
247+ defaults := DefaultConfig()
248+
249+ // Note: Database.Path is set dynamically in Load(), no defaults needed
250+
251+ // History defaults
252+ m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
253+ m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
254+ m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
255+
256+ // Search shortcuts defaults
257+ m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
258+ m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
259+
260+ // Dmenu defaults
261+ m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
262+ m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
263+ m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
264+ m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
265+ m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
266+ m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
267+ m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
268+ m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
269+
270+ // Logging defaults
271+ m.viper.SetDefault("logging.level", defaults.Logging.Level)
272+ m.viper.SetDefault("logging.format", defaults.Logging.Format)
273+ m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
274+ m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
275+ m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
276+ m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
277+ m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
278+ m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
279+ m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
280+ m.viper.SetDefault("logging.capture_stdout", defaults.Logging.CaptureStdout)
281+ m.viper.SetDefault("logging.capture_stderr", defaults.Logging.CaptureStderr)
282+ m.viper.SetDefault("logging.capture_c_output", defaults.Logging.CaptureCOutput)
283+ m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
284+ m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
285+ m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
286+
287+ // Debug defaults
288+ m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
289+ m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
290+ m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
291+ m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
292+ m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
293+ m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
294+ m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
295+ m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
296+ m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
297+ m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
298+ m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
299+ m.viper.SetDefault("debug.enable_pane_close_debug", defaults.Debug.EnablePaneCloseDebug)
300+
301+ // Appearance defaults
302+ m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
303+ m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
304+ m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
305+ m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
306+ m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
307+ m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
308+ m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
309+
310+ // Video acceleration defaults
311+ m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
312+ m.viper.SetDefault("video_acceleration.auto_detect_gpu", defaults.VideoAcceleration.AutoDetectGPU)
313+ m.viper.SetDefault("video_acceleration.vaapi_driver_name", defaults.VideoAcceleration.VAAPIDriverName)
314+ m.viper.SetDefault("video_acceleration.enable_all_drivers", defaults.VideoAcceleration.EnableAllDrivers)
315+ m.viper.SetDefault("video_acceleration.legacy_vaapi", defaults.VideoAcceleration.LegacyVAAPI)
316+
317+ // Codec preferences defaults
318+ m.viper.SetDefault("codec_preferences.preferred_codecs", defaults.CodecPreferences.PreferredCodecs)
319+ m.viper.SetDefault("codec_preferences.force_av1", defaults.CodecPreferences.ForceAV1)
320+ m.viper.SetDefault("codec_preferences.block_vp9", defaults.CodecPreferences.BlockVP9)
321+ m.viper.SetDefault("codec_preferences.block_vp8", defaults.CodecPreferences.BlockVP8)
322+ m.viper.SetDefault("codec_preferences.av1_hardware_only", defaults.CodecPreferences.AV1HardwareOnly)
323+ m.viper.SetDefault("codec_preferences.disable_vp9_hardware", defaults.CodecPreferences.DisableVP9Hardware)
324+ m.viper.SetDefault("codec_preferences.video_buffer_size_mb", defaults.CodecPreferences.VideoBufferSizeMB)
325+ m.viper.SetDefault("codec_preferences.queue_buffer_time_sec", defaults.CodecPreferences.QueueBufferTimeSec)
326+ m.viper.SetDefault("codec_preferences.custom_user_agent", defaults.CodecPreferences.CustomUserAgent)
327+ m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
328+ m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
329+
330+ // Rendering defaults
331+ m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
332+ m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
333+ m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
334+
335+ // Workspace defaults
336+ m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
337+ m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
338+ m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
339+ m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
340+ m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
341+ m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
342+ m.viper.SetDefault("workspace.tabs.new_tab", defaults.Workspace.Tabs.NewTab)
343+ m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
344+ m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
345+ m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
346+ m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
347+ m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
348+ m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
349+ m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
350+ m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
351+ m.viper.SetDefault("workspace.popups.blank_target_behavior", defaults.Workspace.Popups.BlankTargetBehavior)
352+ m.viper.SetDefault("workspace.popups.enable_smart_detection", defaults.Workspace.Popups.EnableSmartDetection)
353+ m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
354+ m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
355+ m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
356+ m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)
357+ m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
358+ m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
359+ m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
360+ m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
361+ m.viper.SetDefault("workspace.styling.ui_scale", defaults.Workspace.Styling.UIScale)
362+
363+ // Content filtering
364+ m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
365+ m.viper.SetDefault("content_filtering.filter_lists", defaults.ContentFiltering.FilterLists)
366+
367+ // Omnibox defaults
368+ m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
369+}
370+
371+// New returns a new default configuration instance.
372+// This is a convenience function for getting default config without the full manager.
373+func New() *Config {
374+ return DefaultConfig()
375+}
376+
377+// Global configuration manager instance
378+var globalManager *Manager
379+var globalManagerOnce sync.Once
380+
381+// Init initializes the global configuration manager.
382+func Init() error {
383+ var err error
384+ globalManagerOnce.Do(func() {
385+ globalManager, err = NewManager()
386+ if err != nil {
387+ return
388+ }
389+ err = globalManager.Load()
390+ })
391+ return err
392+}
393+
394+// Get returns the global configuration.
395+func Get() *Config {
396+ if globalManager == nil {
397+ // Return defaults if not initialized
398+ return DefaultConfig()
399+ }
400+ return globalManager.Get()
401+}
402+
403+// GetManager returns the global configuration manager.
404+// This is useful for accessing watcher functionality.
405+func GetManager() *Manager {
406+ return globalManager
407+}
A internal/infrastructure/config/schema.go +343 -0
@@ -0,0 +1,343 @@
1+package config
2+
3+// Config represents the complete configuration for dumber.
4+type Config struct {
5+ Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"`
6+ History HistoryConfig `mapstructure:"history" yaml:"history" toml:"history"`
7+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts" toml:"search_shortcuts"`
8+ // DefaultSearchEngine is the URL template for the default search engine (must contain %s placeholder)
9+ DefaultSearchEngine string `mapstructure:"default_search_engine" yaml:"default_search_engine" toml:"default_search_engine"`
10+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu" toml:"dmenu"`
11+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging" toml:"logging"`
12+ Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance" toml:"appearance"`
13+ VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration" toml:"video_acceleration"`
14+ CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences" toml:"codec_preferences"`
15+ Debug DebugConfig `mapstructure:"debug" yaml:"debug" toml:"debug"`
16+ // RenderingMode controls GPU/CPU rendering selection for WebKit
17+ RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode" toml:"rendering_mode"`
18+ // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
19+ UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom" toml:"use_dom_zoom"`
20+ // DefaultZoom sets the default zoom level for pages without saved zoom settings (1.0 = 100%, 1.2 = 120%)
21+ DefaultZoom float64 `mapstructure:"default_zoom" yaml:"default_zoom" toml:"default_zoom"`
22+ // Workspace defines workspace, pane, and tab handling behaviour.
23+ Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
24+ // ContentFiltering controls ad blocking and content filtering
25+ ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
26+ // Omnibox controls the omnibox behavior (initial history display)
27+ Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
28+}
29+
30+// RenderingMode selects GPU vs CPU rendering.
31+type RenderingMode string
32+
33+const (
34+ RenderingModeAuto RenderingMode = "auto"
35+ RenderingModeGPU RenderingMode = "gpu"
36+ RenderingModeCPU RenderingMode = "cpu"
37+)
38+
39+// ThemeDefault is the default theme setting (follows system).
40+const ThemeDefault = "default"
41+
42+// DatabaseConfig holds database-related configuration.
43+type DatabaseConfig struct {
44+ Path string `mapstructure:"path" yaml:"path" toml:"path"`
45+}
46+
47+// HistoryConfig holds history-related configuration.
48+type HistoryConfig struct {
49+ MaxEntries int `mapstructure:"max_entries" yaml:"max_entries" toml:"max_entries"`
50+ RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days" toml:"retention_period_days"`
51+ CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
52+}
53+
54+// SearchShortcut represents a search shortcut configuration.
55+type SearchShortcut struct {
56+ URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
57+ Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
58+}
59+
60+// DmenuConfig holds dmenu/rofi integration configuration.
61+type DmenuConfig struct {
62+ MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
63+ ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
64+ ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
65+ HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
66+ ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix" toml:"shortcut_prefix"`
67+ URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix" toml:"url_prefix"`
68+ DateFormat string `mapstructure:"date_format" yaml:"date_format" toml:"date_format"`
69+ SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count" toml:"sort_by_visit_count"`
70+}
71+
72+// LoggingConfig holds logging configuration.
73+type LoggingConfig struct {
74+ Level string `mapstructure:"level" yaml:"level" toml:"level"`
75+ Format string `mapstructure:"format" yaml:"format" toml:"format"`
76+ Filename string `mapstructure:"filename" yaml:"filename" toml:"filename"`
77+ MaxSize int `mapstructure:"max_size" yaml:"max_size" toml:"max_size"`
78+ MaxBackups int `mapstructure:"max_backups" yaml:"max_backups" toml:"max_backups"`
79+ MaxAge int `mapstructure:"max_age" yaml:"max_age" toml:"max_age"`
80+ Compress bool `mapstructure:"compress" yaml:"compress" toml:"compress"`
81+
82+ // File output configuration
83+ LogDir string `mapstructure:"log_dir" yaml:"log_dir" toml:"log_dir"`
84+ EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log" toml:"enable_file_log"`
85+
86+ // Capture settings
87+ CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout" toml:"capture_stdout" json:"capture_stdout"`
88+ CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr" toml:"capture_stderr" json:"capture_stderr"`
89+ CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output" toml:"capture_c_output" json:"capture_c_output"`
90+ CaptureConsole bool `mapstructure:"capture_console" yaml:"capture_console" toml:"capture_console" json:"capture_console"`
91+
92+ // Debug output
93+ DebugFile string `mapstructure:"debug_file" yaml:"debug_file" toml:"debug_file"`
94+ VerboseWebKit bool `mapstructure:"verbose_webkit" yaml:"verbose_webkit" toml:"verbose_webkit"`
95+}
96+
97+// AppearanceConfig holds UI/rendering preferences.
98+type AppearanceConfig struct {
99+ // Default fonts for pages that do not specify fonts.
100+ SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
101+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
102+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
103+ // Default font size in CSS pixels (approx).
104+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
105+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
106+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
107+ // ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
108+ ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
109+}
110+
111+// ColorPalette contains semantic color tokens for light/dark themes.
112+type ColorPalette struct {
113+ Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
114+ Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
115+ SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
116+ Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
117+ Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
118+ Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
119+ Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
120+}
121+
122+// VideoAccelerationConfig holds video hardware acceleration preferences.
123+type VideoAccelerationConfig struct {
124+ EnableVAAPI bool `mapstructure:"enable_vaapi" yaml:"enable_vaapi" toml:"enable_vaapi"`
125+ AutoDetectGPU bool `mapstructure:"auto_detect_gpu" yaml:"auto_detect_gpu" toml:"auto_detect_gpu"`
126+ VAAPIDriverName string `mapstructure:"vaapi_driver_name" yaml:"vaapi_driver_name" toml:"vaapi_driver_name"`
127+ EnableAllDrivers bool `mapstructure:"enable_all_drivers" yaml:"enable_all_drivers" toml:"enable_all_drivers"`
128+ LegacyVAAPI bool `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi" toml:"legacy_vaapi"`
129+}
130+
131+// CodecConfig holds video codec preferences and handling
132+type CodecConfig struct {
133+ // Codec preference order (e.g., "av1,h264,vp8")
134+ PreferredCodecs string `mapstructure:"preferred_codecs" yaml:"preferred_codecs" toml:"preferred_codecs"`
135+
136+ // Force specific codec for platforms
137+ ForceAV1 bool `mapstructure:"force_av1" yaml:"force_av1" toml:"force_av1"`
138+
139+ // Block problematic codecs
140+ BlockVP9 bool `mapstructure:"block_vp9" yaml:"block_vp9" toml:"block_vp9"`
141+ BlockVP8 bool `mapstructure:"block_vp8" yaml:"block_vp8" toml:"block_vp8"`
142+
143+ // Hardware acceleration per codec
144+ AV1HardwareOnly bool `mapstructure:"av1_hardware_only" yaml:"av1_hardware_only" toml:"av1_hardware_only"`
145+ DisableVP9Hardware bool `mapstructure:"disable_vp9_hardware" yaml:"disable_vp9_hardware" toml:"disable_vp9_hardware"`
146+
147+ // Buffer configuration for smooth playback
148+ VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb" toml:"video_buffer_size_mb"`
149+ QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec" toml:"queue_buffer_time_sec"`
150+
151+ // Custom User-Agent for codec negotiation
152+ CustomUserAgent string `mapstructure:"custom_user_agent" yaml:"custom_user_agent" toml:"custom_user_agent"`
153+
154+ // Maximum resolution for AV1 codec (720p, 1080p, 1440p, 4k, unlimited)
155+ AV1MaxResolution string `mapstructure:"av1_max_resolution" yaml:"av1_max_resolution" toml:"av1_max_resolution"`
156+
157+ // Site-specific codec control settings
158+ DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control" toml:"disable_twitch_codec_control"`
159+}
160+
161+// ContentFilteringConfig holds content filtering and ad blocking preferences
162+type ContentFilteringConfig struct {
163+ // Enabled controls whether ad blocking is active (default: true)
164+ Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
165+ // FilterLists URLs of filter lists to use (EasyList, uBlock, etc.)
166+ FilterLists []string `mapstructure:"filter_lists" yaml:"filter_lists" toml:"filter_lists"`
167+ // Note: Whitelist is now managed via database (content_whitelist table)
168+}
169+
170+// OmniboxConfig holds omnibox behavior preferences
171+type OmniboxConfig struct {
172+ // InitialBehavior controls what to show when omnibox opens with empty input
173+ // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
174+ InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`
175+}
176+
177+// DebugConfig holds debug and troubleshooting options
178+type DebugConfig struct {
179+ // Enable WebKit internal debug logging
180+ EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug" toml:"enable_webkit_debug"`
181+
182+ // WebKit debug categories (comma-separated)
183+ // Common values: "Network:preconnectTo", "ContentFilters", "Loading", "JavaScript"
184+ WebKitDebugCategories string `mapstructure:"webkit_debug_categories" yaml:"webkit_debug_categories" toml:"webkit_debug_categories"`
185+
186+ // Enable content filtering debug logs
187+ EnableFilteringDebug bool `mapstructure:"enable_filtering_debug" yaml:"enable_filtering_debug" toml:"enable_filtering_debug"`
188+
189+ // Enable detailed WebView state logging
190+ EnableWebViewDebug bool `mapstructure:"enable_webview_debug" yaml:"enable_webview_debug" toml:"enable_webview_debug"`
191+
192+ // Log WebKit crashes and errors to file
193+ LogWebKitCrashes bool `mapstructure:"log_webkit_crashes" yaml:"log_webkit_crashes" toml:"log_webkit_crashes"`
194+
195+ // Enable script injection debug logs
196+ EnableScriptDebug bool `mapstructure:"enable_script_debug" yaml:"enable_script_debug" toml:"enable_script_debug"`
197+
198+ // Enable general debug mode (equivalent to DUMBER_DEBUG env var)
199+ EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug" toml:"enable_general_debug"`
200+
201+ // Enable workspace navigation and focus debug logs
202+ EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug" toml:"enable_workspace_debug"`
203+
204+ // Enable focus state machine debug logs
205+ EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug" toml:"enable_focus_debug"`
206+
207+ // Enable CSS reconciler debug logs
208+ EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug" toml:"enable_css_debug"`
209+
210+ // Enable focus metrics tracking
211+ EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics" toml:"enable_focus_metrics"`
212+
213+ // Enable detailed pane close instrumentation and tree snapshots
214+ EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug" toml:"enable_pane_close_debug"`
215+}
216+
217+// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
218+type WorkspaceConfig struct {
219+ // PaneMode defines modal pane behaviour and bindings.
220+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
221+ // TabMode defines modal tab behaviour and bindings (Alt+T).
222+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
223+ // Tabs holds classic browser tab shortcuts.
224+ Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" toml:"tabs" json:"tabs"`
225+ // TabBarPosition determines tab bar placement: "top" or "bottom".
226+ TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
227+ // Popups configures default popup placement rules.
228+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
229+ // Styling configures workspace visual appearance.
230+ Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
231+}
232+
233+// PaneModeConfig defines modal behaviour for pane management.
234+type PaneModeConfig struct {
235+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"`
236+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
237+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
238+}
239+
240+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
241+// This is built from the action→keys structure in the config.
242+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
243+ keyToAction := make(map[string]string)
244+ for action, keys := range p.Actions {
245+ for _, key := range keys {
246+ keyToAction[key] = action
247+ }
248+ }
249+ return keyToAction
250+}
251+
252+// TabModeConfig defines modal behaviour for tab management (Zellij-style).
253+type TabModeConfig struct {
254+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"`
255+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
256+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
257+}
258+
259+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
260+// This is built from the action→keys structure in the config.
261+func (t *TabModeConfig) GetKeyBindings() map[string]string {
262+ keyToAction := make(map[string]string)
263+ for action, keys := range t.Actions {
264+ for _, key := range keys {
265+ keyToAction[key] = action
266+ }
267+ }
268+ return keyToAction
269+}
270+
271+// TabKeyConfig defines Zellij-inspired tab shortcuts.
272+type TabKeyConfig struct {
273+ NewTab string `mapstructure:"new_tab" yaml:"new_tab" toml:"new_tab" json:"new_tab"`
274+ CloseTab string `mapstructure:"close_tab" yaml:"close_tab" toml:"close_tab" json:"close_tab"`
275+ NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
276+ PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`
277+}
278+
279+// PopupBehavior defines how popup windows should be opened
280+type PopupBehavior string
281+
282+const (
283+ // PopupBehaviorSplit opens popups in a split pane (default)
284+ PopupBehaviorSplit PopupBehavior = "split"
285+ // PopupBehaviorStacked opens popups in a stacked pane
286+ PopupBehaviorStacked PopupBehavior = "stacked"
287+ // PopupBehaviorTabbed opens popups as a new tab
288+ PopupBehaviorTabbed PopupBehavior = "tabbed"
289+ // PopupBehaviorWindowed opens popups in a new workspace window
290+ PopupBehaviorWindowed PopupBehavior = "windowed"
291+)
292+
293+// PopupBehaviorConfig defines handling for popup windows.
294+type PopupBehaviorConfig struct {
295+ // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
296+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
297+
298+ // Placement specifies direction for split behavior ("right", "left", "top", "bottom")
299+ // Only used when Behavior is "split"
300+ Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
301+
302+ // OpenInNewPane controls whether popups are opened in workspace or blocked
303+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
304+
305+ // FollowPaneContext determines if popup placement follows parent pane context
306+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"`
307+
308+ // BlankTargetBehavior determines how target="_blank" links are opened
309+ // Accepted values: "split", "stacked" (default), "tabbed"
310+ // This is separate from Behavior which controls JavaScript popups
311+ BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"`
312+
313+ // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
314+ EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"`
315+
316+ // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
317+ OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
318+}
319+
320+// WorkspaceStylingConfig defines visual styling for workspace panes.
321+type WorkspaceStylingConfig struct {
322+ // BorderWidth in pixels for active pane borders (overlay)
323+ BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
324+ // BorderColor for focused panes (CSS color value or theme variable)
325+ BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
326+
327+ // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)
328+ PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"`
329+ // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
330+ // Defaults to "#4A90E2" (blue) if not set
331+ PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"`
332+
333+ // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)
334+ TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"`
335+ // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
336+ // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
337+ TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"`
338+
339+ // TransitionDuration in milliseconds for border animations
340+ TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"`
341+ // UIScale is a multiplier for UI elements like title bars (1.0 = 100%, 1.2 = 120%)
342+ UIScale float64 `mapstructure:"ui_scale" yaml:"ui_scale" toml:"ui_scale" json:"ui_scale"`
343+}
@@ -0,0 +1,343 @@
1+package config
2+
3+// Config represents the complete configuration for dumber.
4+type Config struct {
5+ Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"`
6+ History HistoryConfig `mapstructure:"history" yaml:"history" toml:"history"`
7+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts" toml:"search_shortcuts"`
8+ // DefaultSearchEngine is the URL template for the default search engine (must contain %s placeholder)
9+ DefaultSearchEngine string `mapstructure:"default_search_engine" yaml:"default_search_engine" toml:"default_search_engine"`
10+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu" toml:"dmenu"`
11+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging" toml:"logging"`
12+ Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance" toml:"appearance"`
13+ VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration" toml:"video_acceleration"`
14+ CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences" toml:"codec_preferences"`
15+ Debug DebugConfig `mapstructure:"debug" yaml:"debug" toml:"debug"`
16+ // RenderingMode controls GPU/CPU rendering selection for WebKit
17+ RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode" toml:"rendering_mode"`
18+ // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
19+ UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom" toml:"use_dom_zoom"`
20+ // DefaultZoom sets the default zoom level for pages without saved zoom settings (1.0 = 100%, 1.2 = 120%)
21+ DefaultZoom float64 `mapstructure:"default_zoom" yaml:"default_zoom" toml:"default_zoom"`
22+ // Workspace defines workspace, pane, and tab handling behaviour.
23+ Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
24+ // ContentFiltering controls ad blocking and content filtering
25+ ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
26+ // Omnibox controls the omnibox behavior (initial history display)
27+ Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
28+}
29+
30+// RenderingMode selects GPU vs CPU rendering.
31+type RenderingMode string
32+
33+const (
34+ RenderingModeAuto RenderingMode = "auto"
35+ RenderingModeGPU RenderingMode = "gpu"
36+ RenderingModeCPU RenderingMode = "cpu"
37+)
38+
39+// ThemeDefault is the default theme setting (follows system).
40+const ThemeDefault = "default"
41+
42+// DatabaseConfig holds database-related configuration.
43+type DatabaseConfig struct {
44+ Path string `mapstructure:"path" yaml:"path" toml:"path"`
45+}
46+
47+// HistoryConfig holds history-related configuration.
48+type HistoryConfig struct {
49+ MaxEntries int `mapstructure:"max_entries" yaml:"max_entries" toml:"max_entries"`
50+ RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days" toml:"retention_period_days"`
51+ CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
52+}
53+
54+// SearchShortcut represents a search shortcut configuration.
55+type SearchShortcut struct {
56+ URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
57+ Description string `mapstructure:"description" toml:"description" yaml:"description" json:"description"`
58+}
59+
60+// DmenuConfig holds dmenu/rofi integration configuration.
61+type DmenuConfig struct {
62+ MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`
63+ ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
64+ ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
65+ HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
66+ ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix" toml:"shortcut_prefix"`
67+ URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix" toml:"url_prefix"`
68+ DateFormat string `mapstructure:"date_format" yaml:"date_format" toml:"date_format"`
69+ SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count" toml:"sort_by_visit_count"`
70+}
71+
72+// LoggingConfig holds logging configuration.
73+type LoggingConfig struct {
74+ Level string `mapstructure:"level" yaml:"level" toml:"level"`
75+ Format string `mapstructure:"format" yaml:"format" toml:"format"`
76+ Filename string `mapstructure:"filename" yaml:"filename" toml:"filename"`
77+ MaxSize int `mapstructure:"max_size" yaml:"max_size" toml:"max_size"`
78+ MaxBackups int `mapstructure:"max_backups" yaml:"max_backups" toml:"max_backups"`
79+ MaxAge int `mapstructure:"max_age" yaml:"max_age" toml:"max_age"`
80+ Compress bool `mapstructure:"compress" yaml:"compress" toml:"compress"`
81+
82+ // File output configuration
83+ LogDir string `mapstructure:"log_dir" yaml:"log_dir" toml:"log_dir"`
84+ EnableFileLog bool `mapstructure:"enable_file_log" yaml:"enable_file_log" toml:"enable_file_log"`
85+
86+ // Capture settings
87+ CaptureStdout bool `mapstructure:"capture_stdout" yaml:"capture_stdout" toml:"capture_stdout" json:"capture_stdout"`
88+ CaptureStderr bool `mapstructure:"capture_stderr" yaml:"capture_stderr" toml:"capture_stderr" json:"capture_stderr"`
89+ CaptureCOutput bool `mapstructure:"capture_c_output" yaml:"capture_c_output" toml:"capture_c_output" json:"capture_c_output"`
90+ CaptureConsole bool `mapstructure:"capture_console" yaml:"capture_console" toml:"capture_console" json:"capture_console"`
91+
92+ // Debug output
93+ DebugFile string `mapstructure:"debug_file" yaml:"debug_file" toml:"debug_file"`
94+ VerboseWebKit bool `mapstructure:"verbose_webkit" yaml:"verbose_webkit" toml:"verbose_webkit"`
95+}
96+
97+// AppearanceConfig holds UI/rendering preferences.
98+type AppearanceConfig struct {
99+ // Default fonts for pages that do not specify fonts.
100+ SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
101+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
102+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
103+ // Default font size in CSS pixels (approx).
104+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
105+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
106+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
107+ // ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
108+ ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
109+}
110+
111+// ColorPalette contains semantic color tokens for light/dark themes.
112+type ColorPalette struct {
113+ Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
114+ Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
115+ SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
116+ Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
117+ Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
118+ Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
119+ Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
120+}
121+
122+// VideoAccelerationConfig holds video hardware acceleration preferences.
123+type VideoAccelerationConfig struct {
124+ EnableVAAPI bool `mapstructure:"enable_vaapi" yaml:"enable_vaapi" toml:"enable_vaapi"`
125+ AutoDetectGPU bool `mapstructure:"auto_detect_gpu" yaml:"auto_detect_gpu" toml:"auto_detect_gpu"`
126+ VAAPIDriverName string `mapstructure:"vaapi_driver_name" yaml:"vaapi_driver_name" toml:"vaapi_driver_name"`
127+ EnableAllDrivers bool `mapstructure:"enable_all_drivers" yaml:"enable_all_drivers" toml:"enable_all_drivers"`
128+ LegacyVAAPI bool `mapstructure:"legacy_vaapi" yaml:"legacy_vaapi" toml:"legacy_vaapi"`
129+}
130+
131+// CodecConfig holds video codec preferences and handling
132+type CodecConfig struct {
133+ // Codec preference order (e.g., "av1,h264,vp8")
134+ PreferredCodecs string `mapstructure:"preferred_codecs" yaml:"preferred_codecs" toml:"preferred_codecs"`
135+
136+ // Force specific codec for platforms
137+ ForceAV1 bool `mapstructure:"force_av1" yaml:"force_av1" toml:"force_av1"`
138+
139+ // Block problematic codecs
140+ BlockVP9 bool `mapstructure:"block_vp9" yaml:"block_vp9" toml:"block_vp9"`
141+ BlockVP8 bool `mapstructure:"block_vp8" yaml:"block_vp8" toml:"block_vp8"`
142+
143+ // Hardware acceleration per codec
144+ AV1HardwareOnly bool `mapstructure:"av1_hardware_only" yaml:"av1_hardware_only" toml:"av1_hardware_only"`
145+ DisableVP9Hardware bool `mapstructure:"disable_vp9_hardware" yaml:"disable_vp9_hardware" toml:"disable_vp9_hardware"`
146+
147+ // Buffer configuration for smooth playback
148+ VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb" toml:"video_buffer_size_mb"`
149+ QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec" toml:"queue_buffer_time_sec"`
150+
151+ // Custom User-Agent for codec negotiation
152+ CustomUserAgent string `mapstructure:"custom_user_agent" yaml:"custom_user_agent" toml:"custom_user_agent"`
153+
154+ // Maximum resolution for AV1 codec (720p, 1080p, 1440p, 4k, unlimited)
155+ AV1MaxResolution string `mapstructure:"av1_max_resolution" yaml:"av1_max_resolution" toml:"av1_max_resolution"`
156+
157+ // Site-specific codec control settings
158+ DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control" toml:"disable_twitch_codec_control"`
159+}
160+
161+// ContentFilteringConfig holds content filtering and ad blocking preferences
162+type ContentFilteringConfig struct {
163+ // Enabled controls whether ad blocking is active (default: true)
164+ Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
165+ // FilterLists URLs of filter lists to use (EasyList, uBlock, etc.)
166+ FilterLists []string `mapstructure:"filter_lists" yaml:"filter_lists" toml:"filter_lists"`
167+ // Note: Whitelist is now managed via database (content_whitelist table)
168+}
169+
170+// OmniboxConfig holds omnibox behavior preferences
171+type OmniboxConfig struct {
172+ // InitialBehavior controls what to show when omnibox opens with empty input
173+ // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
174+ InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`
175+}
176+
177+// DebugConfig holds debug and troubleshooting options
178+type DebugConfig struct {
179+ // Enable WebKit internal debug logging
180+ EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug" toml:"enable_webkit_debug"`
181+
182+ // WebKit debug categories (comma-separated)
183+ // Common values: "Network:preconnectTo", "ContentFilters", "Loading", "JavaScript"
184+ WebKitDebugCategories string `mapstructure:"webkit_debug_categories" yaml:"webkit_debug_categories" toml:"webkit_debug_categories"`
185+
186+ // Enable content filtering debug logs
187+ EnableFilteringDebug bool `mapstructure:"enable_filtering_debug" yaml:"enable_filtering_debug" toml:"enable_filtering_debug"`
188+
189+ // Enable detailed WebView state logging
190+ EnableWebViewDebug bool `mapstructure:"enable_webview_debug" yaml:"enable_webview_debug" toml:"enable_webview_debug"`
191+
192+ // Log WebKit crashes and errors to file
193+ LogWebKitCrashes bool `mapstructure:"log_webkit_crashes" yaml:"log_webkit_crashes" toml:"log_webkit_crashes"`
194+
195+ // Enable script injection debug logs
196+ EnableScriptDebug bool `mapstructure:"enable_script_debug" yaml:"enable_script_debug" toml:"enable_script_debug"`
197+
198+ // Enable general debug mode (equivalent to DUMBER_DEBUG env var)
199+ EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug" toml:"enable_general_debug"`
200+
201+ // Enable workspace navigation and focus debug logs
202+ EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug" toml:"enable_workspace_debug"`
203+
204+ // Enable focus state machine debug logs
205+ EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug" toml:"enable_focus_debug"`
206+
207+ // Enable CSS reconciler debug logs
208+ EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug" toml:"enable_css_debug"`
209+
210+ // Enable focus metrics tracking
211+ EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics" toml:"enable_focus_metrics"`
212+
213+ // Enable detailed pane close instrumentation and tree snapshots
214+ EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug" toml:"enable_pane_close_debug"`
215+}
216+
217+// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
218+type WorkspaceConfig struct {
219+ // PaneMode defines modal pane behaviour and bindings.
220+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
221+ // TabMode defines modal tab behaviour and bindings (Alt+T).
222+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
223+ // Tabs holds classic browser tab shortcuts.
224+ Tabs TabKeyConfig `mapstructure:"tabs" yaml:"tabs" toml:"tabs" json:"tabs"`
225+ // TabBarPosition determines tab bar placement: "top" or "bottom".
226+ TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
227+ // Popups configures default popup placement rules.
228+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
229+ // Styling configures workspace visual appearance.
230+ Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
231+}
232+
233+// PaneModeConfig defines modal behaviour for pane management.
234+type PaneModeConfig struct {
235+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"`
236+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
237+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
238+}
239+
240+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
241+// This is built from the action→keys structure in the config.
242+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
243+ keyToAction := make(map[string]string)
244+ for action, keys := range p.Actions {
245+ for _, key := range keys {
246+ keyToAction[key] = action
247+ }
248+ }
249+ return keyToAction
250+}
251+
252+// TabModeConfig defines modal behaviour for tab management (Zellij-style).
253+type TabModeConfig struct {
254+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"`
255+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
256+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
257+}
258+
259+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
260+// This is built from the action→keys structure in the config.
261+func (t *TabModeConfig) GetKeyBindings() map[string]string {
262+ keyToAction := make(map[string]string)
263+ for action, keys := range t.Actions {
264+ for _, key := range keys {
265+ keyToAction[key] = action
266+ }
267+ }
268+ return keyToAction
269+}
270+
271+// TabKeyConfig defines Zellij-inspired tab shortcuts.
272+type TabKeyConfig struct {
273+ NewTab string `mapstructure:"new_tab" yaml:"new_tab" toml:"new_tab" json:"new_tab"`
274+ CloseTab string `mapstructure:"close_tab" yaml:"close_tab" toml:"close_tab" json:"close_tab"`
275+ NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
276+ PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`
277+}
278+
279+// PopupBehavior defines how popup windows should be opened
280+type PopupBehavior string
281+
282+const (
283+ // PopupBehaviorSplit opens popups in a split pane (default)
284+ PopupBehaviorSplit PopupBehavior = "split"
285+ // PopupBehaviorStacked opens popups in a stacked pane
286+ PopupBehaviorStacked PopupBehavior = "stacked"
287+ // PopupBehaviorTabbed opens popups as a new tab
288+ PopupBehaviorTabbed PopupBehavior = "tabbed"
289+ // PopupBehaviorWindowed opens popups in a new workspace window
290+ PopupBehaviorWindowed PopupBehavior = "windowed"
291+)
292+
293+// PopupBehaviorConfig defines handling for popup windows.
294+type PopupBehaviorConfig struct {
295+ // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
296+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
297+
298+ // Placement specifies direction for split behavior ("right", "left", "top", "bottom")
299+ // Only used when Behavior is "split"
300+ Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
301+
302+ // OpenInNewPane controls whether popups are opened in workspace or blocked
303+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
304+
305+ // FollowPaneContext determines if popup placement follows parent pane context
306+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"`
307+
308+ // BlankTargetBehavior determines how target="_blank" links are opened
309+ // Accepted values: "split", "stacked" (default), "tabbed"
310+ // This is separate from Behavior which controls JavaScript popups
311+ BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"`
312+
313+ // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
314+ EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"`
315+
316+ // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
317+ OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
318+}
319+
320+// WorkspaceStylingConfig defines visual styling for workspace panes.
321+type WorkspaceStylingConfig struct {
322+ // BorderWidth in pixels for active pane borders (overlay)
323+ BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
324+ // BorderColor for focused panes (CSS color value or theme variable)
325+ BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
326+
327+ // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)
328+ PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"`
329+ // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
330+ // Defaults to "#4A90E2" (blue) if not set
331+ PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"`
332+
333+ // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)
334+ TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"`
335+ // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
336+ // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
337+ TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"`
338+
339+ // TransitionDuration in milliseconds for border animations
340+ TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"`
341+ // UIScale is a multiplier for UI elements like title bars (1.0 = 100%, 1.2 = 120%)
342+ UIScale float64 `mapstructure:"ui_scale" yaml:"ui_scale" toml:"ui_scale" json:"ui_scale"`
343+}
A internal/infrastructure/config/validation.go +216 -0
@@ -0,0 +1,216 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// validateConfig performs comprehensive validation of configuration values
9+func validateConfig(config *Config) error {
10+ var validationErrors []string
11+
12+ // Validate numeric ranges
13+ if config.History.MaxEntries < 0 {
14+ validationErrors = append(validationErrors, "history.max_entries must be non-negative")
15+ }
16+ if config.History.RetentionPeriodDays < 0 {
17+ validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
18+ }
19+ if config.History.CleanupIntervalDays < 0 {
20+ validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
21+ }
22+
23+ if config.Dmenu.MaxHistoryItems < 0 {
24+ validationErrors = append(validationErrors, "dmenu.max_history_items must be non-negative")
25+ }
26+
27+ if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
28+ validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
29+ }
30+
31+ if config.DefaultZoom < 0.1 || config.DefaultZoom > 5.0 {
32+ validationErrors = append(validationErrors, "default_zoom must be between 0.1 and 5.0")
33+ }
34+
35+ // Validate default search engine
36+ if config.DefaultSearchEngine == "" {
37+ validationErrors = append(validationErrors, "default_search_engine cannot be empty")
38+ } else if !strings.Contains(config.DefaultSearchEngine, "%s") {
39+ validationErrors = append(validationErrors, "default_search_engine must contain %s placeholder for the search query")
40+ }
41+
42+ // Validate popup behavior using switch statement (per CLAUDE.md)
43+ switch config.Workspace.Popups.Behavior {
44+ case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
45+ // Valid
46+ default:
47+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.Popups.Behavior))
48+ }
49+
50+ // Validate popup placement for split behavior using switch statement
51+ if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
52+ switch config.Workspace.Popups.Placement {
53+ case "right", "left", "top", "bottom":
54+ // Valid
55+ default:
56+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.Popups.Placement))
57+ }
58+ }
59+
60+ // Validate blank target behavior using switch statement
61+ switch config.Workspace.Popups.BlankTargetBehavior {
62+ case "split", "stacked", "tabbed":
63+ // Valid
64+ default:
65+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
66+ }
67+
68+ // Validate workspace styling values
69+ if config.Workspace.Styling.BorderWidth < 0 {
70+ validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
71+ }
72+ if config.Workspace.Styling.PaneModeBorderWidth < 0 {
73+ validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")
74+ }
75+ if config.Workspace.Styling.TabModeBorderWidth < 0 {
76+ validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
77+ }
78+ if config.Workspace.Styling.TransitionDuration < 0 {
79+ validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
80+ }
81+ if config.Workspace.Styling.UIScale < 0.5 || config.Workspace.Styling.UIScale > 3.0 {
82+ validationErrors = append(validationErrors, "workspace.styling.ui_scale must be between 0.5 and 3.0")
83+ }
84+
85+ // Validate pane mode timeout
86+ if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
87+ validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
88+ }
89+
90+ // Validate pane mode actions
91+ if len(config.Workspace.PaneMode.Actions) == 0 {
92+ validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
93+ }
94+
95+ // Check for duplicate keys and empty action key lists in pane mode
96+ seenKeys := make(map[string]string)
97+ for action, keys := range config.Workspace.PaneMode.Actions {
98+ if len(keys) == 0 {
99+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
100+ }
101+ for _, key := range keys {
102+ if existingAction, exists := seenKeys[key]; exists {
103+ validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", key, existingAction, action))
104+ }
105+ seenKeys[key] = action
106+ }
107+ }
108+
109+ // Validate tab bar position using switch statement
110+ switch config.Workspace.TabBarPosition {
111+ case "top", "bottom":
112+ // Valid
113+ default:
114+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition))
115+ }
116+
117+ // Validate tab mode timeout
118+ if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
119+ validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
120+ }
121+
122+ // Validate tab mode actions
123+ if len(config.Workspace.TabMode.Actions) == 0 {
124+ validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
125+ }
126+
127+ // Check for duplicate keys and empty action key lists in tab mode
128+ tabSeenKeys := make(map[string]string)
129+ for action, keys := range config.Workspace.TabMode.Actions {
130+ if len(keys) == 0 {
131+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
132+ }
133+ for _, key := range keys {
134+ if existingAction, exists := tabSeenKeys[key]; exists {
135+ validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", key, existingAction, action))
136+ }
137+ tabSeenKeys[key] = action
138+ }
139+ }
140+
141+ // Validate codec buffer sizes
142+ if config.CodecPreferences.VideoBufferSizeMB < 0 {
143+ validationErrors = append(validationErrors, "codec_preferences.video_buffer_size_mb must be non-negative")
144+ }
145+ if config.CodecPreferences.QueueBufferTimeSec < 0 {
146+ validationErrors = append(validationErrors, "codec_preferences.queue_buffer_time_sec must be non-negative")
147+ }
148+
149+ // Validate AV1 max resolution using switch statement
150+ if config.CodecPreferences.AV1MaxResolution != "" {
151+ switch config.CodecPreferences.AV1MaxResolution {
152+ case "720p", "1080p", "1440p", "4k", "unlimited":
153+ // Valid
154+ default:
155+ validationErrors = append(validationErrors, fmt.Sprintf("codec_preferences.av1_max_resolution must be one of: 720p, 1080p, 1440p, 4k, unlimited (got: %s)", config.CodecPreferences.AV1MaxResolution))
156+ }
157+ }
158+
159+ // Validate logging values
160+ if config.Logging.MaxSize < 0 {
161+ validationErrors = append(validationErrors, "logging.max_size must be non-negative")
162+ }
163+ if config.Logging.MaxBackups < 0 {
164+ validationErrors = append(validationErrors, "logging.max_backups must be non-negative")
165+ }
166+ if config.Logging.MaxAge < 0 {
167+ validationErrors = append(validationErrors, "logging.max_age must be non-negative")
168+ }
169+
170+ // Validate logging level using switch statement
171+ switch config.Logging.Level {
172+ case "trace", "debug", "info", "warn", "error", "fatal", "":
173+ // Valid (empty uses default)
174+ default:
175+ validationErrors = append(validationErrors, fmt.Sprintf("logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)", config.Logging.Level))
176+ }
177+
178+ // Validate logging format using switch statement
179+ switch config.Logging.Format {
180+ case "text", "json", "console", "":
181+ // Valid (empty uses default)
182+ default:
183+ validationErrors = append(validationErrors, fmt.Sprintf("logging.format must be one of: text, json, console (got: %s)", config.Logging.Format))
184+ }
185+
186+ // Validate omnibox initial behavior using switch statement
187+ switch config.Omnibox.InitialBehavior {
188+ case "recent", "most_visited", "none":
189+ // Valid
190+ default:
191+ validationErrors = append(validationErrors, fmt.Sprintf("omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", config.Omnibox.InitialBehavior))
192+ }
193+
194+ // Validate rendering mode using switch statement
195+ switch config.RenderingMode {
196+ case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
197+ // Valid (empty uses default)
198+ default:
199+ validationErrors = append(validationErrors, fmt.Sprintf("rendering_mode must be one of: auto, gpu, cpu (got: %s)", config.RenderingMode))
200+ }
201+
202+ // Validate color scheme using switch statement
203+ switch config.Appearance.ColorScheme {
204+ case "prefer-dark", "prefer-light", ThemeDefault, "":
205+ // Valid (empty uses default)
206+ default:
207+ validationErrors = append(validationErrors, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", config.Appearance.ColorScheme))
208+ }
209+
210+ // If there are validation errors, return them
211+ if len(validationErrors) > 0 {
212+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
213+ }
214+
215+ return nil
216+}
@@ -0,0 +1,216 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// validateConfig performs comprehensive validation of configuration values
9+func validateConfig(config *Config) error {
10+ var validationErrors []string
11+
12+ // Validate numeric ranges
13+ if config.History.MaxEntries < 0 {
14+ validationErrors = append(validationErrors, "history.max_entries must be non-negative")
15+ }
16+ if config.History.RetentionPeriodDays < 0 {
17+ validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
18+ }
19+ if config.History.CleanupIntervalDays < 0 {
20+ validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
21+ }
22+
23+ if config.Dmenu.MaxHistoryItems < 0 {
24+ validationErrors = append(validationErrors, "dmenu.max_history_items must be non-negative")
25+ }
26+
27+ if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
28+ validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
29+ }
30+
31+ if config.DefaultZoom < 0.1 || config.DefaultZoom > 5.0 {
32+ validationErrors = append(validationErrors, "default_zoom must be between 0.1 and 5.0")
33+ }
34+
35+ // Validate default search engine
36+ if config.DefaultSearchEngine == "" {
37+ validationErrors = append(validationErrors, "default_search_engine cannot be empty")
38+ } else if !strings.Contains(config.DefaultSearchEngine, "%s") {
39+ validationErrors = append(validationErrors, "default_search_engine must contain %s placeholder for the search query")
40+ }
41+
42+ // Validate popup behavior using switch statement (per CLAUDE.md)
43+ switch config.Workspace.Popups.Behavior {
44+ case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
45+ // Valid
46+ default:
47+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.Popups.Behavior))
48+ }
49+
50+ // Validate popup placement for split behavior using switch statement
51+ if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
52+ switch config.Workspace.Popups.Placement {
53+ case "right", "left", "top", "bottom":
54+ // Valid
55+ default:
56+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.Popups.Placement))
57+ }
58+ }
59+
60+ // Validate blank target behavior using switch statement
61+ switch config.Workspace.Popups.BlankTargetBehavior {
62+ case "split", "stacked", "tabbed":
63+ // Valid
64+ default:
65+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
66+ }
67+
68+ // Validate workspace styling values
69+ if config.Workspace.Styling.BorderWidth < 0 {
70+ validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
71+ }
72+ if config.Workspace.Styling.PaneModeBorderWidth < 0 {
73+ validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")
74+ }
75+ if config.Workspace.Styling.TabModeBorderWidth < 0 {
76+ validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
77+ }
78+ if config.Workspace.Styling.TransitionDuration < 0 {
79+ validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
80+ }
81+ if config.Workspace.Styling.UIScale < 0.5 || config.Workspace.Styling.UIScale > 3.0 {
82+ validationErrors = append(validationErrors, "workspace.styling.ui_scale must be between 0.5 and 3.0")
83+ }
84+
85+ // Validate pane mode timeout
86+ if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
87+ validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
88+ }
89+
90+ // Validate pane mode actions
91+ if len(config.Workspace.PaneMode.Actions) == 0 {
92+ validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
93+ }
94+
95+ // Check for duplicate keys and empty action key lists in pane mode
96+ seenKeys := make(map[string]string)
97+ for action, keys := range config.Workspace.PaneMode.Actions {
98+ if len(keys) == 0 {
99+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
100+ }
101+ for _, key := range keys {
102+ if existingAction, exists := seenKeys[key]; exists {
103+ validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", key, existingAction, action))
104+ }
105+ seenKeys[key] = action
106+ }
107+ }
108+
109+ // Validate tab bar position using switch statement
110+ switch config.Workspace.TabBarPosition {
111+ case "top", "bottom":
112+ // Valid
113+ default:
114+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition))
115+ }
116+
117+ // Validate tab mode timeout
118+ if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
119+ validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
120+ }
121+
122+ // Validate tab mode actions
123+ if len(config.Workspace.TabMode.Actions) == 0 {
124+ validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
125+ }
126+
127+ // Check for duplicate keys and empty action key lists in tab mode
128+ tabSeenKeys := make(map[string]string)
129+ for action, keys := range config.Workspace.TabMode.Actions {
130+ if len(keys) == 0 {
131+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
132+ }
133+ for _, key := range keys {
134+ if existingAction, exists := tabSeenKeys[key]; exists {
135+ validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", key, existingAction, action))
136+ }
137+ tabSeenKeys[key] = action
138+ }
139+ }
140+
141+ // Validate codec buffer sizes
142+ if config.CodecPreferences.VideoBufferSizeMB < 0 {
143+ validationErrors = append(validationErrors, "codec_preferences.video_buffer_size_mb must be non-negative")
144+ }
145+ if config.CodecPreferences.QueueBufferTimeSec < 0 {
146+ validationErrors = append(validationErrors, "codec_preferences.queue_buffer_time_sec must be non-negative")
147+ }
148+
149+ // Validate AV1 max resolution using switch statement
150+ if config.CodecPreferences.AV1MaxResolution != "" {
151+ switch config.CodecPreferences.AV1MaxResolution {
152+ case "720p", "1080p", "1440p", "4k", "unlimited":
153+ // Valid
154+ default:
155+ validationErrors = append(validationErrors, fmt.Sprintf("codec_preferences.av1_max_resolution must be one of: 720p, 1080p, 1440p, 4k, unlimited (got: %s)", config.CodecPreferences.AV1MaxResolution))
156+ }
157+ }
158+
159+ // Validate logging values
160+ if config.Logging.MaxSize < 0 {
161+ validationErrors = append(validationErrors, "logging.max_size must be non-negative")
162+ }
163+ if config.Logging.MaxBackups < 0 {
164+ validationErrors = append(validationErrors, "logging.max_backups must be non-negative")
165+ }
166+ if config.Logging.MaxAge < 0 {
167+ validationErrors = append(validationErrors, "logging.max_age must be non-negative")
168+ }
169+
170+ // Validate logging level using switch statement
171+ switch config.Logging.Level {
172+ case "trace", "debug", "info", "warn", "error", "fatal", "":
173+ // Valid (empty uses default)
174+ default:
175+ validationErrors = append(validationErrors, fmt.Sprintf("logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)", config.Logging.Level))
176+ }
177+
178+ // Validate logging format using switch statement
179+ switch config.Logging.Format {
180+ case "text", "json", "console", "":
181+ // Valid (empty uses default)
182+ default:
183+ validationErrors = append(validationErrors, fmt.Sprintf("logging.format must be one of: text, json, console (got: %s)", config.Logging.Format))
184+ }
185+
186+ // Validate omnibox initial behavior using switch statement
187+ switch config.Omnibox.InitialBehavior {
188+ case "recent", "most_visited", "none":
189+ // Valid
190+ default:
191+ validationErrors = append(validationErrors, fmt.Sprintf("omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", config.Omnibox.InitialBehavior))
192+ }
193+
194+ // Validate rendering mode using switch statement
195+ switch config.RenderingMode {
196+ case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
197+ // Valid (empty uses default)
198+ default:
199+ validationErrors = append(validationErrors, fmt.Sprintf("rendering_mode must be one of: auto, gpu, cpu (got: %s)", config.RenderingMode))
200+ }
201+
202+ // Validate color scheme using switch statement
203+ switch config.Appearance.ColorScheme {
204+ case "prefer-dark", "prefer-light", ThemeDefault, "":
205+ // Valid (empty uses default)
206+ default:
207+ validationErrors = append(validationErrors, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", config.Appearance.ColorScheme))
208+ }
209+
210+ // If there are validation errors, return them
211+ if len(validationErrors) > 0 {
212+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
213+ }
214+
215+ return nil
216+}
A internal/infrastructure/config/watcher.go +130 -0
@@ -0,0 +1,130 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+
8+ "github.com/fsnotify/fsnotify"
9+)
10+
11+// Watch starts watching the config file for changes and reloads automatically.
12+func (m *Manager) Watch() error {
13+ m.mu.Lock()
14+ defer m.mu.Unlock()
15+
16+ if m.watching {
17+ return nil // Already watching
18+ }
19+
20+ m.viper.WatchConfig()
21+ m.viper.OnConfigChange(func(e fsnotify.Event) {
22+ // Reload config
23+ if err := m.reload(); err != nil {
24+ fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
25+ return
26+ }
27+
28+ // Notify callbacks
29+ m.mu.RLock()
30+ config := m.config
31+ callbacks := make([]func(*Config), len(m.callbacks))
32+ copy(callbacks, m.callbacks)
33+ m.mu.RUnlock()
34+
35+ for _, callback := range callbacks {
36+ callback(config)
37+ }
38+ })
39+
40+ m.watching = true
41+ return nil
42+}
43+
44+// OnConfigChange registers a callback function to be called when config changes.
45+func (m *Manager) OnConfigChange(callback func(*Config)) {
46+ m.mu.Lock()
47+ defer m.mu.Unlock()
48+
49+ m.callbacks = append(m.callbacks, callback)
50+}
51+
52+// reload reloads the configuration (internal method, must be called with lock held for write).
53+func (m *Manager) reload() error {
54+ if err := m.viper.ReadInConfig(); err != nil {
55+ return err
56+ }
57+
58+ config := &Config{}
59+ if err := m.viper.Unmarshal(config); err != nil {
60+ return err
61+ }
62+
63+ // Set database path if not specified
64+ if config.Database.Path == "" {
65+ dbPath, err := GetDatabaseFile()
66+ if err != nil {
67+ return fmt.Errorf("failed to get database path: %w", err)
68+ }
69+ config.Database.Path = dbPath
70+ }
71+
72+ // Normalize/validate rendering mode using switch statement
73+ switch strings.ToLower(string(config.RenderingMode)) {
74+ case "", string(RenderingModeAuto):
75+ config.RenderingMode = RenderingModeAuto
76+ case string(RenderingModeGPU):
77+ config.RenderingMode = RenderingModeGPU
78+ case string(RenderingModeCPU):
79+ config.RenderingMode = RenderingModeCPU
80+ default:
81+ config.RenderingMode = RenderingModeAuto
82+ }
83+
84+ // TODO: implement in Step 18 - Auto-detect GPU if enabled and driver name is not set
85+ // if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
86+ // gpuInfo := gpu.DetectGPU()
87+ // if gpuInfo.SupportsVAAPI() {
88+ // config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
89+ // }
90+ // }
91+
92+ // TODO: implement in Step 18 - Validate and configure codec preferences based on GPU
93+ // config = m.validateAndConfigureCodecPreferences(config)
94+
95+ // Validate ColorScheme setting using switch statement
96+ switch config.Appearance.ColorScheme {
97+ case "prefer-dark", "prefer-light", ThemeDefault:
98+ // Valid values - no changes needed
99+ case "":
100+ // Empty string is treated the same as "default"
101+ config.Appearance.ColorScheme = ThemeDefault
102+ default:
103+ // Invalid value - reset to default
104+ config.Appearance.ColorScheme = ThemeDefault
105+ }
106+
107+ // Validate all config values
108+ if err := validateConfig(config); err != nil {
109+ return fmt.Errorf("configuration validation failed: %w", err)
110+ }
111+
112+ m.config = config
113+ return nil
114+}
115+
116+// Watch starts watching the global configuration for changes.
117+func Watch() error {
118+ if globalManager == nil {
119+ return fmt.Errorf("configuration not initialized")
120+ }
121+ return globalManager.Watch()
122+}
123+
124+// OnConfigChange registers a callback for global configuration changes.
125+func OnConfigChange(callback func(*Config)) {
126+ if globalManager == nil {
127+ return
128+ }
129+ globalManager.OnConfigChange(callback)
130+}
@@ -0,0 +1,130 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+
8+ "github.com/fsnotify/fsnotify"
9+)
10+
11+// Watch starts watching the config file for changes and reloads automatically.
12+func (m *Manager) Watch() error {
13+ m.mu.Lock()
14+ defer m.mu.Unlock()
15+
16+ if m.watching {
17+ return nil // Already watching
18+ }
19+
20+ m.viper.WatchConfig()
21+ m.viper.OnConfigChange(func(e fsnotify.Event) {
22+ // Reload config
23+ if err := m.reload(); err != nil {
24+ fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
25+ return
26+ }
27+
28+ // Notify callbacks
29+ m.mu.RLock()
30+ config := m.config
31+ callbacks := make([]func(*Config), len(m.callbacks))
32+ copy(callbacks, m.callbacks)
33+ m.mu.RUnlock()
34+
35+ for _, callback := range callbacks {
36+ callback(config)
37+ }
38+ })
39+
40+ m.watching = true
41+ return nil
42+}
43+
44+// OnConfigChange registers a callback function to be called when config changes.
45+func (m *Manager) OnConfigChange(callback func(*Config)) {
46+ m.mu.Lock()
47+ defer m.mu.Unlock()
48+
49+ m.callbacks = append(m.callbacks, callback)
50+}
51+
52+// reload reloads the configuration (internal method, must be called with lock held for write).
53+func (m *Manager) reload() error {
54+ if err := m.viper.ReadInConfig(); err != nil {
55+ return err
56+ }
57+
58+ config := &Config{}
59+ if err := m.viper.Unmarshal(config); err != nil {
60+ return err
61+ }
62+
63+ // Set database path if not specified
64+ if config.Database.Path == "" {
65+ dbPath, err := GetDatabaseFile()
66+ if err != nil {
67+ return fmt.Errorf("failed to get database path: %w", err)
68+ }
69+ config.Database.Path = dbPath
70+ }
71+
72+ // Normalize/validate rendering mode using switch statement
73+ switch strings.ToLower(string(config.RenderingMode)) {
74+ case "", string(RenderingModeAuto):
75+ config.RenderingMode = RenderingModeAuto
76+ case string(RenderingModeGPU):
77+ config.RenderingMode = RenderingModeGPU
78+ case string(RenderingModeCPU):
79+ config.RenderingMode = RenderingModeCPU
80+ default:
81+ config.RenderingMode = RenderingModeAuto
82+ }
83+
84+ // TODO: implement in Step 18 - Auto-detect GPU if enabled and driver name is not set
85+ // if config.VideoAcceleration.AutoDetectGPU && config.VideoAcceleration.VAAPIDriverName == "" {
86+ // gpuInfo := gpu.DetectGPU()
87+ // if gpuInfo.SupportsVAAPI() {
88+ // config.VideoAcceleration.VAAPIDriverName = gpuInfo.GetVAAPIDriverName()
89+ // }
90+ // }
91+
92+ // TODO: implement in Step 18 - Validate and configure codec preferences based on GPU
93+ // config = m.validateAndConfigureCodecPreferences(config)
94+
95+ // Validate ColorScheme setting using switch statement
96+ switch config.Appearance.ColorScheme {
97+ case "prefer-dark", "prefer-light", ThemeDefault:
98+ // Valid values - no changes needed
99+ case "":
100+ // Empty string is treated the same as "default"
101+ config.Appearance.ColorScheme = ThemeDefault
102+ default:
103+ // Invalid value - reset to default
104+ config.Appearance.ColorScheme = ThemeDefault
105+ }
106+
107+ // Validate all config values
108+ if err := validateConfig(config); err != nil {
109+ return fmt.Errorf("configuration validation failed: %w", err)
110+ }
111+
112+ m.config = config
113+ return nil
114+}
115+
116+// Watch starts watching the global configuration for changes.
117+func Watch() error {
118+ if globalManager == nil {
119+ return fmt.Errorf("configuration not initialized")
120+ }
121+ return globalManager.Watch()
122+}
123+
124+// OnConfigChange registers a callback for global configuration changes.
125+func OnConfigChange(callback func(*Config)) {
126+ if globalManager == nil {
127+ return
128+ }
129+ globalManager.OnConfigChange(callback)
130+}
A internal/infrastructure/config/xdg.go +177 -0
@@ -0,0 +1,177 @@
1+// Package config provides configuration management for dumber with Viper integration.
2+package config
3+
4+import (
5+ "os"
6+ "path/filepath"
7+)
8+
9+// File permission constants
10+const (
11+ dirPerm = 0755 // Standard directory permissions (rwxr-xr-x)
12+ filePerm = 0644 // Standard file permissions (rw-r--r--)
13+)
14+
15+const (
16+ appName = "dumber"
17+ databaseName = "dumber.sqlite"
18+)
19+
20+// XDGDirs holds the XDG Base Directory paths for the application.
21+type XDGDirs struct {
22+ ConfigHome string
23+ DataHome string
24+ StateHome string
25+}
26+
27+// GetXDGDirs returns the XDG Base Directory paths for dumber.
28+// It follows the XDG Base Directory specification:
29+// - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber)
30+// - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber)
31+// - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber)
32+func GetXDGDirs() (*XDGDirs, error) {
33+ // Development mode: use .dev directory in current working directory
34+ if os.Getenv("ENV") == "dev" {
35+ cwd, err := os.Getwd()
36+ if err != nil {
37+ return nil, err
38+ }
39+ devDir := filepath.Join(cwd, ".dev", appName)
40+ return &XDGDirs{
41+ ConfigHome: devDir,
42+ DataHome: devDir,
43+ StateHome: devDir,
44+ }, nil
45+ }
46+
47+ homeDir, err := os.UserHomeDir()
48+ if err != nil {
49+ return nil, err
50+ }
51+
52+ // XDG_CONFIG_HOME
53+ configHome := os.Getenv("XDG_CONFIG_HOME")
54+ if configHome == "" {
55+ configHome = filepath.Join(homeDir, ".config")
56+ }
57+ configHome = filepath.Join(configHome, appName)
58+
59+ // XDG_DATA_HOME
60+ dataHome := os.Getenv("XDG_DATA_HOME")
61+ if dataHome == "" {
62+ dataHome = filepath.Join(homeDir, ".local", "share")
63+ }
64+ dataHome = filepath.Join(dataHome, appName)
65+
66+ // XDG_STATE_HOME
67+ stateHome := os.Getenv("XDG_STATE_HOME")
68+ if stateHome == "" {
69+ stateHome = filepath.Join(homeDir, ".local", "state")
70+ }
71+ stateHome = filepath.Join(stateHome, appName)
72+
73+ return &XDGDirs{
74+ ConfigHome: configHome,
75+ DataHome: dataHome,
76+ StateHome: stateHome,
77+ }, nil
78+}
79+
80+// GetConfigDir returns the XDG config directory for dumber.
81+func GetConfigDir() (string, error) {
82+ dirs, err := GetXDGDirs()
83+ if err != nil {
84+ return "", err
85+ }
86+ return dirs.ConfigHome, nil
87+}
88+
89+// GetDataDir returns the XDG data directory for dumber.
90+func GetDataDir() (string, error) {
91+ dirs, err := GetXDGDirs()
92+ if err != nil {
93+ return "", err
94+ }
95+ return dirs.DataHome, nil
96+}
97+
98+// GetStateDir returns the XDG state directory for dumber.
99+func GetStateDir() (string, error) {
100+ dirs, err := GetXDGDirs()
101+ if err != nil {
102+ return "", err
103+ }
104+ return dirs.StateHome, nil
105+}
106+
107+// GetLogDir returns the XDG-compliant log directory for dumber.
108+// Logs are stored in XDG_STATE_HOME as per specification.
109+func GetLogDir() (string, error) {
110+ stateDir, err := GetStateDir()
111+ if err != nil {
112+ return "", err
113+ }
114+ return filepath.Join(stateDir, "logs"), nil
115+}
116+
117+// GetConfigFile returns the path to the main configuration file.
118+func GetConfigFile() (string, error) {
119+ configDir, err := GetConfigDir()
120+ if err != nil {
121+ return "", err
122+ }
123+ return filepath.Join(configDir, "config.toml"), nil
124+}
125+
126+// GetDatabaseFile returns the path to the database file in the data directory.
127+// The database contains important user data (history, cookies, preferences) and
128+// therefore belongs in XDG_DATA_HOME, not XDG_STATE_HOME.
129+func GetDatabaseFile() (string, error) {
130+ dataDir, err := GetDataDir()
131+ if err != nil {
132+ return "", err
133+ }
134+ return filepath.Join(dataDir, databaseName), nil
135+}
136+
137+// GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
138+// Filter cache is stored in XDG_STATE_HOME as it's transient data that can be regenerated.
139+func GetFilterCacheDir() (string, error) {
140+ stateDir, err := GetStateDir()
141+ if err != nil {
142+ return "", err
143+ }
144+ return filepath.Join(stateDir, "filter-cache"), nil
145+}
146+
147+// GetFilterCacheFile returns the path to the main filter cache file.
148+func GetFilterCacheFile() (string, error) {
149+ cacheDir, err := GetFilterCacheDir()
150+ if err != nil {
151+ return "", err
152+ }
153+ return filepath.Join(cacheDir, "filters.cache"), nil
154+}
155+
156+// EnsureDirectories creates the XDG directories if they don't exist.
157+func EnsureDirectories() error {
158+ dirs, err := GetXDGDirs()
159+ if err != nil {
160+ return err
161+ }
162+
163+ // Create all directories with proper permissions
164+ directories := []string{
165+ dirs.ConfigHome,
166+ dirs.DataHome,
167+ dirs.StateHome,
168+ }
169+
170+ for _, dir := range directories {
171+ if err := os.MkdirAll(dir, dirPerm); err != nil {
172+ return err
173+ }
174+ }
175+
176+ return nil
177+}
@@ -0,0 +1,177 @@
1+// Package config provides configuration management for dumber with Viper integration.
2+package config
3+
4+import (
5+ "os"
6+ "path/filepath"
7+)
8+
9+// File permission constants
10+const (
11+ dirPerm = 0755 // Standard directory permissions (rwxr-xr-x)
12+ filePerm = 0644 // Standard file permissions (rw-r--r--)
13+)
14+
15+const (
16+ appName = "dumber"
17+ databaseName = "dumber.sqlite"
18+)
19+
20+// XDGDirs holds the XDG Base Directory paths for the application.
21+type XDGDirs struct {
22+ ConfigHome string
23+ DataHome string
24+ StateHome string
25+}
26+
27+// GetXDGDirs returns the XDG Base Directory paths for dumber.
28+// It follows the XDG Base Directory specification:
29+// - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber)
30+// - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber)
31+// - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber)
32+func GetXDGDirs() (*XDGDirs, error) {
33+ // Development mode: use .dev directory in current working directory
34+ if os.Getenv("ENV") == "dev" {
35+ cwd, err := os.Getwd()
36+ if err != nil {
37+ return nil, err
38+ }
39+ devDir := filepath.Join(cwd, ".dev", appName)
40+ return &XDGDirs{
41+ ConfigHome: devDir,
42+ DataHome: devDir,
43+ StateHome: devDir,
44+ }, nil
45+ }
46+
47+ homeDir, err := os.UserHomeDir()
48+ if err != nil {
49+ return nil, err
50+ }
51+
52+ // XDG_CONFIG_HOME
53+ configHome := os.Getenv("XDG_CONFIG_HOME")
54+ if configHome == "" {
55+ configHome = filepath.Join(homeDir, ".config")
56+ }
57+ configHome = filepath.Join(configHome, appName)
58+
59+ // XDG_DATA_HOME
60+ dataHome := os.Getenv("XDG_DATA_HOME")
61+ if dataHome == "" {
62+ dataHome = filepath.Join(homeDir, ".local", "share")
63+ }
64+ dataHome = filepath.Join(dataHome, appName)
65+
66+ // XDG_STATE_HOME
67+ stateHome := os.Getenv("XDG_STATE_HOME")
68+ if stateHome == "" {
69+ stateHome = filepath.Join(homeDir, ".local", "state")
70+ }
71+ stateHome = filepath.Join(stateHome, appName)
72+
73+ return &XDGDirs{
74+ ConfigHome: configHome,
75+ DataHome: dataHome,
76+ StateHome: stateHome,
77+ }, nil
78+}
79+
80+// GetConfigDir returns the XDG config directory for dumber.
81+func GetConfigDir() (string, error) {
82+ dirs, err := GetXDGDirs()
83+ if err != nil {
84+ return "", err
85+ }
86+ return dirs.ConfigHome, nil
87+}
88+
89+// GetDataDir returns the XDG data directory for dumber.
90+func GetDataDir() (string, error) {
91+ dirs, err := GetXDGDirs()
92+ if err != nil {
93+ return "", err
94+ }
95+ return dirs.DataHome, nil
96+}
97+
98+// GetStateDir returns the XDG state directory for dumber.
99+func GetStateDir() (string, error) {
100+ dirs, err := GetXDGDirs()
101+ if err != nil {
102+ return "", err
103+ }
104+ return dirs.StateHome, nil
105+}
106+
107+// GetLogDir returns the XDG-compliant log directory for dumber.
108+// Logs are stored in XDG_STATE_HOME as per specification.
109+func GetLogDir() (string, error) {
110+ stateDir, err := GetStateDir()
111+ if err != nil {
112+ return "", err
113+ }
114+ return filepath.Join(stateDir, "logs"), nil
115+}
116+
117+// GetConfigFile returns the path to the main configuration file.
118+func GetConfigFile() (string, error) {
119+ configDir, err := GetConfigDir()
120+ if err != nil {
121+ return "", err
122+ }
123+ return filepath.Join(configDir, "config.toml"), nil
124+}
125+
126+// GetDatabaseFile returns the path to the database file in the data directory.
127+// The database contains important user data (history, cookies, preferences) and
128+// therefore belongs in XDG_DATA_HOME, not XDG_STATE_HOME.
129+func GetDatabaseFile() (string, error) {
130+ dataDir, err := GetDataDir()
131+ if err != nil {
132+ return "", err
133+ }
134+ return filepath.Join(dataDir, databaseName), nil
135+}
136+
137+// GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
138+// Filter cache is stored in XDG_STATE_HOME as it's transient data that can be regenerated.
139+func GetFilterCacheDir() (string, error) {
140+ stateDir, err := GetStateDir()
141+ if err != nil {
142+ return "", err
143+ }
144+ return filepath.Join(stateDir, "filter-cache"), nil
145+}
146+
147+// GetFilterCacheFile returns the path to the main filter cache file.
148+func GetFilterCacheFile() (string, error) {
149+ cacheDir, err := GetFilterCacheDir()
150+ if err != nil {
151+ return "", err
152+ }
153+ return filepath.Join(cacheDir, "filters.cache"), nil
154+}
155+
156+// EnsureDirectories creates the XDG directories if they don't exist.
157+func EnsureDirectories() error {
158+ dirs, err := GetXDGDirs()
159+ if err != nil {
160+ return err
161+ }
162+
163+ // Create all directories with proper permissions
164+ directories := []string{
165+ dirs.ConfigHome,
166+ dirs.DataHome,
167+ dirs.StateHome,
168+ }
169+
170+ for _, dir := range directories {
171+ if err := os.MkdirAll(dir, dirPerm); err != nil {
172+ return err
173+ }
174+ }
175+
176+ return nil
177+}
M internal/logging/logger.go +41 -14
@@ -8,7 +8,6 @@ import (
88 "github.com/rs/zerolog"
99 )
1010
11-// TODO should come from config package
1211 // Config holds logging configuration
1312 type Config struct {
1413 Level zerolog.Level
@@ -54,26 +53,54 @@ func NewFromEnv() zerolog.Logger {
5453 cfg := DefaultConfig()
5554
5655 if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
57- switch level {
58- case "trace":
59- cfg.Level = zerolog.TraceLevel
60- case "debug":
61- cfg.Level = zerolog.DebugLevel
62- case "info":
63- cfg.Level = zerolog.InfoLevel
64- case "warn":
65- cfg.Level = zerolog.WarnLevel
66- case "error":
67- cfg.Level = zerolog.ErrorLevel
68- }
56+ cfg.Level = ParseLevel(level)
6957 }
7058
7159 if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
7260 switch format {
73- case "json", "console":
61+ case "json", "console", "text":
7462 cfg.Format = format
7563 }
7664 }
7765
7866 return New(cfg)
7967 }
68+
69+// NewFromConfigValues creates a logger from level and format strings.
70+// This is used by main.go to create a logger from the config package's LoggingConfig
71+// without creating an import cycle.
72+func NewFromConfigValues(level, format string) zerolog.Logger {
73+ cfg := DefaultConfig()
74+ cfg.Level = ParseLevel(level)
75+
76+ switch format {
77+ case "json":
78+ cfg.Format = "json"
79+ case "console", "text", "":
80+ cfg.Format = "console"
81+ default:
82+ cfg.Format = "console"
83+ }
84+
85+ return New(cfg)
86+}
87+
88+// ParseLevel converts a level string to zerolog.Level
89+func ParseLevel(level string) zerolog.Level {
90+ switch level {
91+ case "trace":
92+ return zerolog.TraceLevel
93+ case "debug":
94+ return zerolog.DebugLevel
95+ case "info", "":
96+ return zerolog.InfoLevel
97+ case "warn":
98+ return zerolog.WarnLevel
99+ case "error":
100+ return zerolog.ErrorLevel
101+ case "fatal":
102+ return zerolog.FatalLevel
103+ default:
104+ return zerolog.InfoLevel
105+ }
106+}
@@ -8,7 +8,6 @@ import (
8 "github.com/rs/zerolog"8 "github.com/rs/zerolog"
9 )9 )
10 10
11-// TODO should come from config package
12 // Config holds logging configuration11 // Config holds logging configuration
13 type Config struct {12 type Config struct {
14 Level zerolog.Level13 Level zerolog.Level
@@ -54,26 +53,54 @@ func NewFromEnv() zerolog.Logger {
54 cfg := DefaultConfig()53 cfg := DefaultConfig()
55 54
56 if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {55 if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
57- switch level {56+ cfg.Level = ParseLevel(level)
58- case "trace":
59- cfg.Level = zerolog.TraceLevel
60- case "debug":
61- cfg.Level = zerolog.DebugLevel
62- case "info":
63- cfg.Level = zerolog.InfoLevel
64- case "warn":
65- cfg.Level = zerolog.WarnLevel
66- case "error":
67- cfg.Level = zerolog.ErrorLevel
68- }
69 }57 }
70 58
71 if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {59 if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
72 switch format {60 switch format {
73- case "json", "console":61+ case "json", "console", "text":
74 cfg.Format = format62 cfg.Format = format
75 }63 }
76 }64 }
77 65
78 return New(cfg)66 return New(cfg)
79 }67 }
68+
69+// NewFromConfigValues creates a logger from level and format strings.
70+// This is used by main.go to create a logger from the config package's LoggingConfig
71+// without creating an import cycle.
72+func NewFromConfigValues(level, format string) zerolog.Logger {
73+ cfg := DefaultConfig()
74+ cfg.Level = ParseLevel(level)
75+
76+ switch format {
77+ case "json":
78+ cfg.Format = "json"
79+ case "console", "text", "":
80+ cfg.Format = "console"
81+ default:
82+ cfg.Format = "console"
83+ }
84+
85+ return New(cfg)
86+}
87+
88+// ParseLevel converts a level string to zerolog.Level
89+func ParseLevel(level string) zerolog.Level {
90+ switch level {
91+ case "trace":
92+ return zerolog.TraceLevel
93+ case "debug":
94+ return zerolog.DebugLevel
95+ case "info", "":
96+ return zerolog.InfoLevel
97+ case "warn":
98+ return zerolog.WarnLevel
99+ case "error":
100+ return zerolog.ErrorLevel
101+ case "fatal":
102+ return zerolog.FatalLevel
103+ default:
104+ return zerolog.InfoLevel
105+ }
106+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close