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 | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | + | github.com/fsnotify/fsnotify v1.9.0 | |
| 6 | 7 | github.com/ncruces/go-sqlite3 v0.30.3 | |
| 7 | 8 | github.com/pressly/goose/v3 v3.26.0 | |
| 8 | 9 | github.com/rs/zerolog v1.33.0 | |
| 10 | + | github.com/spf13/viper v1.21.0 | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | require ( | |
| 14 | + | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 12 | 15 | github.com/mattn/go-colorable v0.1.13 // indirect | |
| 13 | 16 | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 14 | 17 | github.com/mfridman/interpolate v0.0.2 // indirect | |
| 15 | 18 | 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 | 21 | 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 | 27 | github.com/tetratelabs/wazero v1.10.1 // indirect | |
| 18 | 28 | go.uber.org/multierr v1.11.0 // indirect | |
| 29 | + | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 19 | 30 | golang.org/x/sync v0.18.0 // indirect | |
| 20 | 31 | golang.org/x/sys v0.38.0 // indirect | |
| 32 | + | golang.org/x/text v0.31.0 // indirect | |
| 21 | 33 | ) | |
| 22 | 34 | ||
| 23 | 35 | replace ( | |
| @@ -3,21 +3,33 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.25.3 | 3 | 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.3 | 7 | github.com/ncruces/go-sqlite3 v0.30.3 | ||
| 7 | github.com/pressly/goose/v3 v3.26.0 | 8 | github.com/pressly/goose/v3 v3.26.0 | ||
| 8 | github.com/rs/zerolog v1.33.0 | 9 | 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 // indirect | 15 | github.com/mattn/go-colorable v0.1.13 // indirect | ||
| 13 | github.com/mattn/go-isatty v0.0.20 // indirect | 16 | github.com/mattn/go-isatty v0.0.20 // indirect | ||
| 14 | github.com/mfridman/interpolate v0.0.2 // indirect | 17 | github.com/mfridman/interpolate v0.0.2 // indirect | ||
| 15 | github.com/ncruces/julianday v1.0.0 // indirect | 18 | 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 // indirect | 21 | 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 // indirect | 27 | github.com/tetratelabs/wazero v1.10.1 // indirect | ||
| 18 | go.uber.org/multierr v1.11.0 // indirect | 28 | 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 // indirect | 30 | golang.org/x/sync v0.18.0 // indirect | ||
| 20 | golang.org/x/sys v0.38.0 // indirect | 31 | 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 ( | |||
| 8 | 8 | "github.com/rs/zerolog" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | // TODO should come from config package | |
| 12 | 11 | // Config holds logging configuration | |
| 13 | 12 | type Config struct { | |
| 14 | 13 | Level zerolog.Level | |
| @@ -54,26 +53,54 @@ func NewFromEnv() zerolog.Logger { | |||
| 54 | 53 | cfg := DefaultConfig() | |
| 55 | 54 | ||
| 56 | 55 | 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) | |
| 69 | 57 | } | |
| 70 | 58 | ||
| 71 | 59 | if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" { | |
| 72 | 60 | switch format { | |
| 73 | - | case "json", "console": | |
| 61 | + | case "json", "console", "text": | |
| 74 | 62 | cfg.Format = format | |
| 75 | 63 | } | |
| 76 | 64 | } | |
| 77 | 65 | ||
| 78 | 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 | + | } | |
| @@ -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 configuration | 11 | // Config holds logging configuration | ||
| 13 | type Config struct { | 12 | type Config struct { | ||
| 14 | Level zerolog.Level | 13 | 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 = format | 62 | 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 | + | } | |||