Merge pull request #7 from bnema/dev
feat: keyboard shortcuts, cache improvements, and error handling
76 files · +3107 -794
M .gitignore +1 -0
| @@ -82,3 +82,4 @@ templates/ | |||
| 82 | 82 | .serena/ | |
| 83 | 83 | config.yaml | |
| 84 | 84 | .dev/ | |
| 85 | + | vendor/ | |
M CHANGELOG.md +20 -0
| @@ -6,6 +6,26 @@ All notable changes to this project will be documented in this file. | |||
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | 8 | - **Color scheme config**: `appearance.color_scheme` setting to force dark/light theme (`"prefer-dark"`, `"prefer-light"`, `"default"`) | |
| 9 | + | - **DNS prefetch**: Added capability to prefetch DNS for links to speed up future requests | |
| 10 | + | - **Page cache (bfcache)**: Enabled WebKit back/forward cache for instant navigation | |
| 11 | + | - **Smooth scrolling**: Enabled WebKit smooth scrolling for better UX | |
| 12 | + | - **JSON Schema**: Auto-generated config schema for IDE autocompletion and validation | |
| 13 | + | - **Parallel cache loading**: Zoom, certificate, and fuzzy caches load concurrently at startup (<100ms target) | |
| 14 | + | - **Certificate validation cache**: In-memory cache for TLS certificate decisions with graceful shutdown flushing | |
| 15 | + | ||
| 16 | + | ### Changed | |
| 17 | + | - **Database performance**: Enabled WAL mode with optimized PRAGMAs (64MB cache, memory-mapped I/O, 5s busy timeout) for 70k reads/sec, 3.6k writes/sec | |
| 18 | + | - **Zoom performance**: In-memory cache eliminates database reads during page transitions | |
| 19 | + | - **History writes**: Batched writes with background processor (flush every 5s or 50 items) to reduce I/O overhead | |
| 20 | + | - **BREAKING: Keyboard shortcuts**: All shortcuts now use `ctrl+` instead of `cmdorctrl+`. Existing configs with `cmdorctrl` still work via backward compatibility. Delete `~/.config/dumber/config.json` to regenerate with new defaults. | |
| 21 | + | - **Cache management**: Graceful cache flushing on shutdown prevents data loss; startup timing instrumentation with performance warnings | |
| 22 | + | ||
| 23 | + | ### Fixed | |
| 24 | + | - **Graceful shutdown deadlock**: Resolved GTK main loop deadlock on Ctrl+Q by removing mutex locks and GTK calls after main loop exit | |
| 25 | + | - **CSS provider error handling**: Now properly handles errors from GTK CSS provider with logging | |
| 26 | + | - **Cache operations**: Added context timeouts to cache flush and history queue operations for graceful failure | |
| 27 | + | - Command typos now show proper error instead of opening in browser | |
| 28 | + | - History queue now uses buffered channel to prevent blocking on shutdown | |
| 9 | 29 | ||
| 10 | 30 | ## [0.12.1] - 2025-10-18 | |
| 11 | 31 | ||
M README.md +32 -128
| @@ -41,22 +41,22 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl | |||
| 41 | 41 | | Shortcut | Action | Notes | | |
| 42 | 42 | |----------|--------|-------| | |
| 43 | 43 | | **F12** | Open Developer Tools | WebKit inspector | | |
| 44 | - | | **Ctrl/Cmd+L** | Open Omnibox | URL/search input with history | | |
| 45 | - | | **Ctrl/Cmd+F** | Find in Page | Search text within current page | | |
| 46 | - | | **Ctrl/Cmd+=** | Zoom In | Firefox-compatible zoom levels | | |
| 47 | - | | **Ctrl/Cmd++** | Zoom In | Alternative plus key | | |
| 48 | - | | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts | | |
| 49 | - | | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom | | |
| 50 | - | | **Ctrl/Cmd+Shift+C** | Copy URL | Copy current URL to clipboard with toast | | |
| 51 | - | | **Ctrl/Cmd+Shift+P** | Print Page | Open native print dialog | | |
| 52 | - | | **Ctrl/Cmd+R** / **F5** | Reload Page | Refresh current page | | |
| 53 | - | | **Ctrl/Cmd+Shift+R** | Hard Reload | Refresh ignoring cache | | |
| 44 | + | | **Ctrl+L** | Open Omnibox | URL/search input with history | | |
| 45 | + | | **Ctrl+F** | Find in Page | Search text within current page | | |
| 46 | + | | **Ctrl+=** | Zoom In | Firefox-compatible zoom levels | | |
| 47 | + | | **Ctrl++** | Zoom In | Alternative plus key | | |
| 48 | + | | **Ctrl+-** | Zoom Out | Works across keyboard layouts | | |
| 49 | + | | **Ctrl+0** | Reset Zoom | Return to 100% zoom | | |
| 50 | + | | **Ctrl+Shift+C** | Copy URL | Copy current URL to clipboard with toast | | |
| 51 | + | | **Ctrl+Shift+P** | Print Page | Open native print dialog | | |
| 52 | + | | **Ctrl+R** / **F5** | Reload Page | Refresh current page | | |
| 53 | + | | **Ctrl+Shift+R** | Hard Reload | Refresh ignoring cache | | |
| 54 | 54 | | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation | | |
| 55 | 55 | ||
| 56 | 56 | #### Zellij-Inspired Pane Management | |
| 57 | 57 | | Shortcut | Action | Notes | | |
| 58 | 58 | |----------|--------|-------| | |
| 59 | - | | **Ctrl/Cmd+P** | Enter Pane Mode | Modal mode for pane operations | | |
| 59 | + | | **Ctrl+P** | Enter Pane Mode | Modal mode for pane operations | | |
| 60 | 60 | | **→** / **R** (in pane mode) | Split Right | Create new pane to the right | | |
| 61 | 61 | | **←** / **L** (in pane mode) | Split Left | Create new pane to the left | | |
| 62 | 62 | | **↑** / **U** (in pane mode) | Split Up | Create new pane above | | |
| @@ -178,7 +178,7 @@ The pane system is fully configurable via `config.json`: | |||
| 178 | 178 | "workspace": { | |
| 179 | 179 | "enable_zellij_controls": true, | |
| 180 | 180 | "pane_mode": { | |
| 181 | - | "activation_shortcut": "cmdorctrl+p", | |
| 181 | + | "activation_shortcut": "ctrl+p", | |
| 182 | 182 | "timeout_ms": 3000, | |
| 183 | 183 | "action_bindings": { | |
| 184 | 184 | "arrowright": "split-right", | |
| @@ -208,134 +208,38 @@ Dumber follows the XDG Base Directory spec: | |||
| 208 | 208 | ||
| 209 | 209 | A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible. | |
| 210 | 210 | ||
| 211 | - | ### Complete Configuration Reference | |
| 211 | + | ### How Configuration Works | |
| 212 | 212 | ||
| 213 | - | #### Database Configuration | |
| 214 | - | ```json | |
| 215 | - | "database": { | |
| 216 | - | "path": "~/.local/state/dumber/history.db", | |
| 217 | - | "max_connections": 1, | |
| 218 | - | "max_idle_time": "5m0s", | |
| 219 | - | "query_timeout": "30s" | |
| 220 | - | } | |
| 221 | - | ``` | |
| 213 | + | - **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too) | |
| 214 | + | - **Format**: JSON/YAML/TOML - auto-detected | |
| 215 | + | - **JSON Schema**: Auto-generated for IDE autocompletion and validation | |
| 216 | + | - **Live reload**: Changes are automatically applied without restarting | |
| 217 | + | - **Environment variables**: Override any setting with `DUMB_BROWSER_*` or `DUMBER_*` prefix | |
| 218 | + | - **Default values**: Sensible defaults for all settings | |
| 222 | 219 | ||
| 223 | - | #### History Management | |
| 224 | - | ```json | |
| 225 | - | "history": { | |
| 226 | - | "max_entries": 10000, | |
| 227 | - | "retention_period_days": 365, | |
| 228 | - | "cleanup_interval_days": 1 | |
| 229 | - | } | |
| 230 | - | ``` | |
| 220 | + | ### Quick Examples | |
| 231 | 221 | ||
| 232 | - | #### Search Shortcuts | |
| 233 | 222 | ```json | |
| 234 | - | "search_shortcuts": { | |
| 235 | - | "g": { "url": "https://www.google.com/search?q=%s", "description": "Google search" }, | |
| 236 | - | "gh": { "url": "https://github.com/search?q=%s", "description": "GitHub search" }, | |
| 237 | - | "yt": { "url": "https://www.youtube.com/results?search_query=%s", "description": "YouTube search" }, | |
| 238 | - | "w": { "url": "https://en.wikipedia.org/wiki/%s", "description": "Wikipedia search" }, | |
| 239 | - | "ddg": { "url": "https://duckduckgo.com/?q=%s", "description": "DuckDuckGo search" }, | |
| 240 | - | "so": { "url": "https://stackoverflow.com/search?q=%s", "description": "Stack Overflow search" }, | |
| 241 | - | "r": { "url": "https://www.reddit.com/search?q=%s", "description": "Reddit search" }, | |
| 242 | - | "npm": { "url": "https://www.npmjs.com/search?q=%s", "description": "npm package search" }, | |
| 243 | - | "go": { "url": "https://pkg.go.dev/search?q=%s", "description": "Go package search" }, | |
| 244 | - | "mdn": { "url": "https://developer.mozilla.org/en-US/search?q=%s", "description": "MDN Web Docs search" } | |
| 245 | - | } | |
| 246 | - | ``` | |
| 247 | - | ||
| 248 | - | #### Dmenu Integration | |
| 249 | - | ```json | |
| 250 | - | "dmenu": { | |
| 251 | - | "max_history_items": 20, | |
| 252 | - | "show_visit_count": true, | |
| 253 | - | "show_last_visited": true, | |
| 254 | - | "history_prefix": "🕒", | |
| 255 | - | "shortcut_prefix": "🔍", | |
| 256 | - | "url_prefix": "🌐", | |
| 257 | - | "date_format": "2006-01-02 15:04", | |
| 258 | - | "sort_by_visit_count": true | |
| 259 | - | } | |
| 260 | - | ``` | |
| 261 | - | ||
| 262 | - | #### Logging Configuration | |
| 263 | - | ```json | |
| 264 | - | "logging": { | |
| 265 | - | "level": "info", | |
| 266 | - | "format": "text", | |
| 267 | - | "filename": "", | |
| 268 | - | "max_size": 100, | |
| 269 | - | "max_backups": 3, | |
| 270 | - | "max_age": 7, | |
| 271 | - | "compress": true | |
| 272 | - | } | |
| 273 | - | ``` | |
| 274 | - | ||
| 275 | - | #### Appearance Settings | |
| 276 | - | ```json | |
| 277 | - | "appearance": { | |
| 278 | - | "color_scheme": "default", | |
| 279 | - | "sans_font": "Fira Sans", | |
| 280 | - | "serif_font": "Fira Sans", | |
| 281 | - | "monospace_font": "Fira Code", | |
| 282 | - | "default_font_size": 16, | |
| 223 | + | { | |
| 283 | 224 | "default_zoom": 1.2, | |
| 284 | - | "color_palette": { | |
| 285 | - | "light": { | |
| 286 | - | "background": "#ffffff", | |
| 287 | - | "surface": "#f5f5f5", | |
| 288 | - | "text": "#1a1a1a", | |
| 289 | - | "text_muted": "#6b7280", | |
| 290 | - | "accent": "#3b82f6", | |
| 291 | - | "border": "#e5e7eb" | |
| 292 | - | }, | |
| 293 | - | "dark": { | |
| 294 | - | "background": "#0a0a0a", | |
| 295 | - | "surface": "#1a1a1a", | |
| 296 | - | "text": "#e5e7eb", | |
| 297 | - | "text_muted": "#9ca3af", | |
| 298 | - | "accent": "#60a5fa", | |
| 299 | - | "border": "#374151" | |
| 300 | - | } | |
| 225 | + | "rendering_mode": "gpu", | |
| 226 | + | "search_shortcuts": { | |
| 227 | + | "g": { "url": "https://google.com/search?q=%s", "description": "Google" } | |
| 228 | + | }, | |
| 229 | + | "workspace": { | |
| 230 | + | "enable_zellij_controls": true, | |
| 231 | + | "popups": { "behavior": "split" } | |
| 301 | 232 | } | |
| 302 | 233 | } | |
| 303 | 234 | ``` | |
| 304 | - | - `color_scheme`: `"prefer-dark"`, `"prefer-light"`, or `"default"` (follows system theme) | |
| 305 | - | ||
| 306 | - | #### Rendering Mode (GPU/CPU) | |
| 307 | - | ```json | |
| 308 | - | "rendering_mode": "auto" | |
| 309 | - | ``` | |
| 310 | - | - `auto`: Detect GPU availability and use GPU when available (default) | |
| 311 | - | - `gpu`: Force GPU acceleration (Vulkan/OpenGL via GTK4) | |
| 312 | - | - `cpu`: Force software rendering | |
| 313 | - | ||
| 314 | - | ### Environment Variables | |
| 315 | - | All config values can be overridden with environment variables using the `DUMB_BROWSER_` prefix: | |
| 316 | 235 | ||
| 236 | + | Environment variable override: | |
| 317 | 237 | ```bash | |
| 318 | - | # Database | |
| 319 | - | DUMB_BROWSER_DATABASE_PATH="./browser.db" | |
| 320 | - | DUMB_BROWSER_DATABASE_MAX_CONNECTIONS=5 | |
| 321 | - | ||
| 322 | - | # Rendering mode (special variable name) | |
| 323 | - | DUMBER_RENDERING_MODE="gpu" | |
| 324 | - | ||
| 325 | - | # Dmenu settings | |
| 326 | - | DUMB_BROWSER_DMENU_MAX_HISTORY_ITEMS=50 | |
| 327 | - | DUMB_BROWSER_DMENU_SHOW_VISIT_COUNT=false | |
| 328 | - | ||
| 329 | - | # Logging | |
| 330 | - | DUMB_BROWSER_LOGGING_LEVEL="debug" | |
| 331 | - | DUMB_BROWSER_LOGGING_FORMAT="json" | |
| 238 | + | DUMBER_RENDERING_MODE=cpu dumber | |
| 239 | + | DUMB_BROWSER_LOGGING_LEVEL=debug dumber | |
| 332 | 240 | ``` | |
| 333 | 241 | ||
| 334 | - | ### CLI Flags | |
| 335 | - | The browse command supports additional runtime flags: | |
| 336 | - | ```bash | |
| 337 | - | dumber browse --rendering-mode=gpu https://example.com | |
| 338 | - | ``` | |
| 242 | + | **📖 Complete documentation**: See [docs/CONFIG.md](docs/CONFIG.md) for all available settings, defaults, and valid values. | |
| 339 | 243 | ||
| 340 | 244 | ||
| 341 | 245 | ## Building With WebKitGTK 6 (GTK4) | |
A docs/CONFIG.md +295 -0
| @@ -0,0 +1,295 @@ | |||
| 1 | + | # Configuration Reference | |
| 2 | + | ||
| 3 | + | **Primary format**: JSON | |
| 4 | + | **Location**: `~/.config/dumber/config.json` | |
| 5 | + | **Also supports**: YAML (`.yaml`) and TOML (`.toml`) | |
| 6 | + | **JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated) | |
| 7 | + | ||
| 8 | + | ## IDE Integration | |
| 9 | + | ||
| 10 | + | For autocompletion and validation in VS Code or other editors, add this to the top of your `config.json`: | |
| 11 | + | ||
| 12 | + | ```json | |
| 13 | + | { | |
| 14 | + | "$schema": "./config.schema.json", | |
| 15 | + | "default_zoom": 1.2, | |
| 16 | + | ... | |
| 17 | + | } | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | The schema is automatically generated when you first create a config file. | |
| 21 | + | ||
| 22 | + | ## Database | |
| 23 | + | ||
| 24 | + | | Key | Type | Default | Description | | |
| 25 | + | |-----|------|---------|-------------| | |
| 26 | + | | `database.path` | string | `~/.local/state/dumber/dumber.sqlite` | Database file path | | |
| 27 | + | ||
| 28 | + | ## History | |
| 29 | + | ||
| 30 | + | | Key | Type | Default | Valid Values | Description | | |
| 31 | + | |-----|------|---------|--------------|-------------| | |
| 32 | + | | `history.max_entries` | int | `10000` | > 0 | Maximum number of history entries | | |
| 33 | + | | `history.retention_period_days` | int | `365` | > 0 | Days to keep history (1 year) | | |
| 34 | + | | `history.cleanup_interval_days` | int | `1` | > 0 | How often to run cleanup | | |
| 35 | + | ||
| 36 | + | ## Search Shortcuts | |
| 37 | + | ||
| 38 | + | | Key | Type | Default | Description | | |
| 39 | + | |-----|------|---------|-------------| | |
| 40 | + | | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs | | |
| 41 | + | ||
| 42 | + | **Default shortcuts:** | |
| 43 | + | ```json | |
| 44 | + | { | |
| 45 | + | "search_shortcuts": { | |
| 46 | + | "g": { | |
| 47 | + | "url": "https://google.com/search?q=%s", | |
| 48 | + | "description": "Google Search" | |
| 49 | + | }, | |
| 50 | + | "gh": { | |
| 51 | + | "url": "https://github.com/search?q=%s", | |
| 52 | + | "description": "GitHub" | |
| 53 | + | }, | |
| 54 | + | "yt": { | |
| 55 | + | "url": "https://youtube.com/results?search_query=%s", | |
| 56 | + | "description": "YouTube" | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ``` | |
| 61 | + | ||
| 62 | + | ## Dmenu / Launcher | |
| 63 | + | ||
| 64 | + | | Key | Type | Default | Description | | |
| 65 | + | |-----|------|---------|-------------| | |
| 66 | + | | `dmenu.max_history_items` | int | `20` | Max items shown in launcher | | |
| 67 | + | | `dmenu.show_visit_count` | bool | `true` | Show visit counts | | |
| 68 | + | | `dmenu.show_last_visited` | bool | `true` | Show last visited dates | | |
| 69 | + | | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items | | |
| 70 | + | | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts | | |
| 71 | + | | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs | | |
| 72 | + | | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string | | |
| 73 | + | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity | | |
| 74 | + | ||
| 75 | + | ## Logging | |
| 76 | + | ||
| 77 | + | | Key | Type | Default | Valid Values | Description | | |
| 78 | + | |-----|------|---------|--------------|-------------| | |
| 79 | + | | `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level | | |
| 80 | + | | `logging.format` | string | `"text"` | `text`, `json` | Log format | | |
| 81 | + | | `logging.filename` | string | `""` | any | Empty = stdout | | |
| 82 | + | | `logging.max_size` | int | `100` | > 0 | Max log size in MB | | |
| 83 | + | | `logging.max_backups` | int | `3` | >= 0 | Number of log backups | | |
| 84 | + | | `logging.max_age` | int | `7` | >= 0 | Days to keep logs | | |
| 85 | + | | `logging.compress` | bool | `true` | - | Compress old logs | | |
| 86 | + | | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory | | |
| 87 | + | | `logging.enable_file_log` | bool | `true` | - | Enable file logging | | |
| 88 | + | | `logging.capture_stdout` | bool | `false` | - | Capture stdout to log | | |
| 89 | + | | `logging.capture_stderr` | bool | `false` | - | Capture stderr to log | | |
| 90 | + | | `logging.capture_c_output` | bool | `false` | - | Capture C/CGO output | | |
| 91 | + | | `logging.capture_console` | bool | `false` | - | Capture browser console | | |
| 92 | + | | `logging.debug_file` | string | `"debug.log"` | any | Debug log filename | | |
| 93 | + | | `logging.verbose_webkit` | bool | `false` | - | Verbose WebKit logs | | |
| 94 | + | ||
| 95 | + | ## Appearance | |
| 96 | + | ||
| 97 | + | | Key | Type | Default | Description | | |
| 98 | + | |-----|------|---------|-------------| | |
| 99 | + | | `appearance.sans_font` | string | `"Fira Sans"` | Sans-serif font | | |
| 100 | + | | `appearance.serif_font` | string | `"Fira Sans"` | Serif font | | |
| 101 | + | | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font | | |
| 102 | + | | `appearance.default_font_size` | int | `16` | Font size in points | | |
| 103 | + | | `appearance.color_scheme` | string | `"default"` | `prefer-dark`, `prefer-light`, `default` | | |
| 104 | + | ||
| 105 | + | ### Color Palettes | |
| 106 | + | ||
| 107 | + | **Light palette:** | |
| 108 | + | ```json | |
| 109 | + | { | |
| 110 | + | "appearance": { | |
| 111 | + | "light_palette": { | |
| 112 | + | "background": "#f8f8f8", | |
| 113 | + | "surface": "#f2f2f2", | |
| 114 | + | "surface_variant": "#ececec", | |
| 115 | + | "text": "#1a1a1a", | |
| 116 | + | "muted": "#6e6e6e", | |
| 117 | + | "accent": "#404040", | |
| 118 | + | "border": "#d2d2d2" | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ``` | |
| 123 | + | ||
| 124 | + | **Dark palette:** | |
| 125 | + | ```json | |
| 126 | + | { | |
| 127 | + | "appearance": { | |
| 128 | + | "dark_palette": { | |
| 129 | + | "background": "#0e0e0e", | |
| 130 | + | "surface": "#1a1a1a", | |
| 131 | + | "surface_variant": "#141414", | |
| 132 | + | "text": "#e4e4e4", | |
| 133 | + | "muted": "#848484", | |
| 134 | + | "accent": "#a8a8a8", | |
| 135 | + | "border": "#363636" | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ``` | |
| 140 | + | ||
| 141 | + | ## Video Acceleration | |
| 142 | + | ||
| 143 | + | | Key | Type | Default | Description | | |
| 144 | + | |-----|------|---------|-------------| | |
| 145 | + | | `video_acceleration.enable_vaapi` | bool | `true` | Enable VA-API hardware accel | | |
| 146 | + | | `video_acceleration.auto_detect_gpu` | bool | `true` | Auto-detect GPU driver | | |
| 147 | + | | `video_acceleration.vaapi_driver_name` | string | `""` | Driver name (auto-detected) | | |
| 148 | + | | `video_acceleration.enable_all_drivers` | bool | `true` | Enable all VA-API drivers | | |
| 149 | + | | `video_acceleration.legacy_vaapi` | bool | `false` | Legacy VA-API mode | | |
| 150 | + | ||
| 151 | + | ## Codec Preferences | |
| 152 | + | ||
| 153 | + | | Key | Type | Default | Valid Values | Description | | |
| 154 | + | |-----|------|---------|--------------|-------------| | |
| 155 | + | | `codec_preferences.preferred_codecs` | string | `"av1,h264"` | Comma-separated | Codec priority order | | |
| 156 | + | | `codec_preferences.force_av1` | bool | `false` | - | Force AV1 codec | | |
| 157 | + | | `codec_preferences.block_vp9` | bool | `false` | - | Block VP9 codec | | |
| 158 | + | | `codec_preferences.block_vp8` | bool | `true` | - | Block VP8 codec | | |
| 159 | + | | `codec_preferences.av1_hardware_only` | bool | `false` | - | Disable AV1 software fallback | | |
| 160 | + | | `codec_preferences.disable_vp9_hardware` | bool | `false` | - | Disable VP9 hardware accel | | |
| 161 | + | | `codec_preferences.video_buffer_size_mb` | int | `16` | > 0 | Video buffer size in MB | | |
| 162 | + | | `codec_preferences.queue_buffer_time_sec` | int | `10` | > 0 | Queue buffer time in seconds | | |
| 163 | + | | `codec_preferences.custom_user_agent` | string | Chrome UA | any | Custom user agent string | | |
| 164 | + | | `codec_preferences.av1_max_resolution` | string | `"1080p"` | `720p`, `1080p`, `1440p`, `4k`, `unlimited` | Max AV1 resolution | | |
| 165 | + | | `codec_preferences.disable_twitch_codec_control` | bool | `true` | - | Disable codec control on Twitch | | |
| 166 | + | ||
| 167 | + | ## Debug Options | |
| 168 | + | ||
| 169 | + | | Key | Type | Default | Description | | |
| 170 | + | |-----|------|---------|-------------| | |
| 171 | + | | `debug.enable_webkit_debug` | bool | `false` | Enable WebKit debug logs | | |
| 172 | + | | `debug.webkit_debug_categories` | string | `"Network:preconnectTo,ContentFilters"` | Debug categories | | |
| 173 | + | | `debug.enable_filtering_debug` | bool | `false` | Content filter debug | | |
| 174 | + | | `debug.enable_webview_debug` | bool | `false` | WebView state debug | | |
| 175 | + | | `debug.log_webkit_crashes` | bool | `true` | Log WebKit crashes | | |
| 176 | + | | `debug.enable_script_debug` | bool | `false` | Script injection debug | | |
| 177 | + | | `debug.enable_general_debug` | bool | `false` | General debug mode | | |
| 178 | + | | `debug.enable_workspace_debug` | bool | `false` | Workspace navigation debug | | |
| 179 | + | | `debug.enable_focus_debug` | bool | `false` | Focus state debug | | |
| 180 | + | | `debug.enable_css_debug` | bool | `false` | CSS reconciler debug | | |
| 181 | + | | `debug.enable_focus_metrics` | bool | `false` | Focus metrics tracking | | |
| 182 | + | | `debug.enable_pane_close_debug` | bool | `false` | Pane close debug | | |
| 183 | + | ||
| 184 | + | ## Rendering & Zoom | |
| 185 | + | ||
| 186 | + | | Key | Type | Default | Valid Values | Description | | |
| 187 | + | |-----|------|---------|--------------|-------------| | |
| 188 | + | | `rendering_mode` | string | `"gpu"` | `auto`, `gpu`, `cpu` | Rendering mode | | |
| 189 | + | | `use_dom_zoom` | bool | `false` | - | Use DOM-based zoom | | |
| 190 | + | | `default_zoom` | float | `1.2` | > 0 | Default zoom level (120%) | | |
| 191 | + | ||
| 192 | + | ## Workspace Configuration | |
| 193 | + | ||
| 194 | + | | Key | Type | Default | Description | | |
| 195 | + | |-----|------|---------|-------------| | |
| 196 | + | | `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings | | |
| 197 | + | ||
| 198 | + | ### Pane Mode | |
| 199 | + | ||
| 200 | + | | Key | Type | Default | Description | | |
| 201 | + | |-----|------|---------|-------------| | |
| 202 | + | | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key | | |
| 203 | + | | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) | | |
| 204 | + | | `workspace.pane_mode.action_bindings` | map | See below | Pane mode key bindings | | |
| 205 | + | ||
| 206 | + | **Default pane mode bindings:** | |
| 207 | + | ```json | |
| 208 | + | { | |
| 209 | + | "workspace": { | |
| 210 | + | "pane_mode": { | |
| 211 | + | "action_bindings": { | |
| 212 | + | "arrowright": "split-right", | |
| 213 | + | "arrowleft": "split-left", | |
| 214 | + | "arrowup": "split-up", | |
| 215 | + | "arrowdown": "split-down", | |
| 216 | + | "r": "split-right", | |
| 217 | + | "l": "split-left", | |
| 218 | + | "u": "split-up", | |
| 219 | + | "d": "split-down", | |
| 220 | + | "x": "close-pane", | |
| 221 | + | "enter": "confirm", | |
| 222 | + | "escape": "cancel" | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ``` | |
| 228 | + | ||
| 229 | + | ### Tab Shortcuts | |
| 230 | + | ||
| 231 | + | | Key | Type | Default | Description | | |
| 232 | + | |-----|------|---------|-------------| | |
| 233 | + | | `workspace.tabs.new_tab` | string | `"ctrl+t"` | New tab shortcut | | |
| 234 | + | | `workspace.tabs.close_tab` | string | `"ctrl+w"` | Close tab shortcut | | |
| 235 | + | | `workspace.tabs.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | |
| 236 | + | | `workspace.tabs.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | |
| 237 | + | ||
| 238 | + | ### Popup Behavior | |
| 239 | + | ||
| 240 | + | | Key | Type | Default | Valid Values | Description | | |
| 241 | + | |-----|------|---------|--------------|-------------| | |
| 242 | + | | `workspace.popups.behavior` | string | `"split"` | `split`, `stacked`, `tabbed`, `windowed` | Popup opening mode | | |
| 243 | + | | `workspace.popups.placement` | string | `"right"` | `right`, `left`, `top`, `bottom` | Split direction | | |
| 244 | + | | `workspace.popups.open_in_new_pane` | bool | `true` | - | Open popups in workspace | | |
| 245 | + | | `workspace.popups.follow_pane_context` | bool | `true` | - | Follow parent pane context | | |
| 246 | + | | `workspace.popups.blank_target_behavior` | string | `"pane"` | `pane`, `tab` | `_blank` target handling | | |
| 247 | + | | `workspace.popups.enable_smart_detection` | bool | `true` | - | Use WindowProperties for detection | | |
| 248 | + | | `workspace.popups.oauth_auto_close` | bool | `true` | - | Auto-close OAuth popups | | |
| 249 | + | ||
| 250 | + | ### Workspace Styling | |
| 251 | + | ||
| 252 | + | | Key | Type | Default | Description | | |
| 253 | + | |-----|------|---------|-------------| | |
| 254 | + | | `workspace.styling.border_width` | int | `2` | Border width in pixels | | |
| 255 | + | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Border color (CSS or theme var) | | |
| 256 | + | | `workspace.styling.pane_mode_border_color` | string | `"#FFA500"` | Pane mode border color (orange) | | |
| 257 | + | | `workspace.styling.transition_duration` | int | `120` | Transition duration (ms) | | |
| 258 | + | | `workspace.styling.border_radius` | int | `0` | Border radius in pixels | | |
| 259 | + | ||
| 260 | + | ## Content Filtering | |
| 261 | + | ||
| 262 | + | | Key | Type | Default | Description | | |
| 263 | + | |-----|------|---------|-------------| | |
| 264 | + | | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking | | |
| 265 | + | ||
| 266 | + | **Default whitelist:** | |
| 267 | + | ```json | |
| 268 | + | { | |
| 269 | + | "content_filtering_whitelist": [ | |
| 270 | + | "twitch.tv", | |
| 271 | + | "passport.twitch.tv", | |
| 272 | + | "gql.twitch.tv" | |
| 273 | + | ] | |
| 274 | + | } | |
| 275 | + | ``` | |
| 276 | + | ||
| 277 | + | ## Environment Variables | |
| 278 | + | ||
| 279 | + | All config values can be overridden via environment variables with the prefix `DUMB_BROWSER_`: | |
| 280 | + | ||
| 281 | + | ```bash | |
| 282 | + | # Database | |
| 283 | + | DUMB_BROWSER_DATABASE_PATH=/custom/path/db.sqlite | |
| 284 | + | ||
| 285 | + | # Rendering | |
| 286 | + | DUMBER_RENDERING_MODE=cpu | |
| 287 | + | DUMBER_DEFAULT_ZOOM=1.5 | |
| 288 | + | ||
| 289 | + | # Video/Codec | |
| 290 | + | DUMBER_VIDEO_ACCELERATION_ENABLE=true | |
| 291 | + | LIBVA_DRIVER_NAME=iHD | |
| 292 | + | DUMBER_PREFERRED_CODECS=av1,vp9,h264 | |
| 293 | + | ``` | |
| 294 | + | ||
| 295 | + | **Note:** Some video/codec env vars use `DUMBER_*` prefix and match GStreamer/VA-API conventions. | |
M go.mod +8 -1
| @@ -7,17 +7,23 @@ require ( | |||
| 7 | 7 | github.com/diamondburned/gotk4/pkg v0.3.1 | |
| 8 | 8 | github.com/fsnotify/fsnotify v1.9.0 | |
| 9 | 9 | github.com/golang/mock v1.6.0 | |
| 10 | + | github.com/invopop/jsonschema v0.13.0 | |
| 10 | 11 | github.com/ncruces/go-sqlite3 v0.29.0 | |
| 11 | 12 | github.com/spf13/cobra v1.10.1 | |
| 12 | 13 | github.com/spf13/viper v1.21.0 | |
| 14 | + | go.uber.org/mock v0.6.0 | |
| 15 | + | golang.org/x/sync v0.17.0 | |
| 13 | 16 | golang.org/x/sys v0.36.0 | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 16 | 19 | require ( | |
| 17 | 20 | github.com/KarpelesLab/weak v0.1.1 // indirect | |
| 21 | + | github.com/bahlo/generic-list-go v0.2.0 // indirect | |
| 22 | + | github.com/buger/jsonparser v1.1.1 // indirect | |
| 18 | 23 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 19 | 24 | github.com/google/go-cmp v0.7.0 // indirect | |
| 20 | 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 26 | + | github.com/mailru/easyjson v0.7.7 // indirect | |
| 21 | 27 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 22 | 28 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 23 | 29 | github.com/rogpeppe/go-internal v1.12.0 // indirect | |
| @@ -28,11 +34,12 @@ require ( | |||
| 28 | 34 | github.com/spf13/pflag v1.0.10 // indirect | |
| 29 | 35 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 30 | 36 | github.com/tetratelabs/wazero v1.9.0 // indirect | |
| 37 | + | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect | |
| 31 | 38 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 32 | 39 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | |
| 33 | - | golang.org/x/sync v0.17.0 // indirect | |
| 34 | 40 | golang.org/x/text v0.29.0 // indirect | |
| 35 | 41 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | |
| 42 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 36 | 43 | ) | |
| 37 | 44 | ||
| 38 | 45 | replace ( | |
M go.sum +13 -0 generated file: collapsed
M gui/package-lock.json +11 -15 generated file: collapsed
M gui/src/lib/keyboard/KeyboardService.ts +5 -10
| @@ -343,13 +343,8 @@ export class KeyboardService { | |||
| 343 | 343 | const parts: string[] = []; | |
| 344 | 344 | ||
| 345 | 345 | // Add modifiers using MODIFIER_KEYS mapping | |
| 346 | - | if (event.ctrlKey && event.metaKey) { | |
| 347 | - | // Both ctrl and meta pressed - use cmdorctrl | |
| 348 | - | parts.push("cmdorctrl"); | |
| 349 | - | } else if (event.ctrlKey) { | |
| 346 | + | if (event.ctrlKey) { | |
| 350 | 347 | parts.push("ctrl"); | |
| 351 | - | } else if (event.metaKey) { | |
| 352 | - | parts.push("cmd"); | |
| 353 | 348 | } | |
| 354 | 349 | ||
| 355 | 350 | if (event.altKey) parts.push("alt"); | |
| @@ -407,20 +402,20 @@ export const keyboardService = new KeyboardService({ | |||
| 407 | 402 | globalShortcuts: [ | |
| 408 | 403 | // Navigation shortcuts (migrated from controls module) | |
| 409 | 404 | { | |
| 410 | - | key: "cmdorctrl+arrowleft", | |
| 405 | + | key: "ctrl+arrowleft", | |
| 411 | 406 | handler: () => { | |
| 412 | 407 | if (window.history.length > 1) { | |
| 413 | 408 | window.history.back(); | |
| 414 | 409 | } | |
| 415 | 410 | }, | |
| 416 | - | description: "Navigate back (Ctrl/Cmd+Left Arrow)", | |
| 411 | + | description: "Navigate back (Ctrl+Left Arrow)", | |
| 417 | 412 | }, | |
| 418 | 413 | { | |
| 419 | - | key: "cmdorctrl+arrowright", | |
| 414 | + | key: "ctrl+arrowright", | |
| 420 | 415 | handler: () => { | |
| 421 | 416 | window.history.forward(); | |
| 422 | 417 | }, | |
| 423 | - | description: "Navigate forward (Ctrl/Cmd+Right Arrow)", | |
| 418 | + | description: "Navigate forward (Ctrl+Right Arrow)", | |
| 424 | 419 | }, | |
| 425 | 420 | // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled | |
| 426 | 421 | // directly by the native Go shortcut handlers to ensure proper active pane detection. | |
M gui/src/lib/keyboard/types.ts +3 -5
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | 8 | export interface ShortcutConfig { | |
| 9 | - | /** The keyboard shortcut key combination (e.g., 'cmdorctrl+l', 'escape', 'enter') */ | |
| 9 | + | /** The keyboard shortcut key combination (e.g., 'ctrl+l', 'escape', 'enter') */ | |
| 10 | 10 | key: string; | |
| 11 | 11 | ||
| 12 | 12 | /** Function to execute when the shortcut is triggered */ | |
| @@ -78,8 +78,6 @@ export interface KeyboardEvent { | |||
| 78 | 78 | */ | |
| 79 | 79 | export const MODIFIER_KEYS = { | |
| 80 | 80 | ctrl: "ctrlKey", | |
| 81 | - | cmd: "metaKey", | |
| 82 | - | cmdorctrl: navigator.platform.includes("Mac") ? "metaKey" : "ctrlKey", | |
| 83 | 81 | alt: "altKey", | |
| 84 | 82 | shift: "shiftKey", | |
| 85 | 83 | } as const; | |
| @@ -88,8 +86,8 @@ export const MODIFIER_KEYS = { | |||
| 88 | 86 | * Common shortcut patterns used throughout the application | |
| 89 | 87 | */ | |
| 90 | 88 | export const COMMON_SHORTCUTS = { | |
| 91 | - | OMNIBOX_OPEN: "cmdorctrl+l", | |
| 92 | - | FIND_OPEN: "cmdorctrl+f", | |
| 89 | + | OMNIBOX_OPEN: "ctrl+l", | |
| 90 | + | FIND_OPEN: "ctrl+f", | |
| 93 | 91 | ESCAPE: "escape", | |
| 94 | 92 | ENTER: "enter", | |
| 95 | 93 | ARROW_UP: "arrowup", | |
M internal/app/browser/browser.go +105 -2
| @@ -1,13 +1,16 @@ | |||
| 1 | 1 | package browser | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "database/sql" | |
| 5 | 6 | "embed" | |
| 7 | + | "fmt" | |
| 6 | 8 | "log" | |
| 7 | 9 | "os" | |
| 8 | 10 | "os/signal" | |
| 9 | 11 | "runtime" | |
| 10 | 12 | "syscall" | |
| 13 | + | "time" | |
| 11 | 14 | ||
| 12 | 15 | "github.com/bnema/dumber/internal/app/api" | |
| 13 | 16 | "github.com/bnema/dumber/internal/app/control" | |
| @@ -19,6 +22,7 @@ import ( | |||
| 19 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 20 | 23 | "github.com/bnema/dumber/internal/services" | |
| 21 | 24 | "github.com/bnema/dumber/pkg/webkit" | |
| 25 | + | "golang.org/x/sync/errgroup" | |
| 22 | 26 | ) | |
| 23 | 27 | ||
| 24 | 28 | // BrowserApp represents the main browser application | |
| @@ -61,6 +65,7 @@ type BrowserApp struct { | |||
| 61 | 65 | ||
| 62 | 66 | // Run starts the browser application | |
| 63 | 67 | func Run(assets embed.FS, version, commit, buildDate string) { | |
| 68 | + | startupStart := time.Now() | |
| 64 | 69 | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | |
| 65 | 70 | ||
| 66 | 71 | app := &BrowserApp{ | |
| @@ -78,6 +83,12 @@ func Run(assets embed.FS, version, commit, buildDate string) { | |||
| 78 | 83 | os.Exit(1) | |
| 79 | 84 | } | |
| 80 | 85 | ||
| 86 | + | startupElapsed := time.Since(startupStart) | |
| 87 | + | log.Printf("[startup] Application initialized in %v", startupElapsed) | |
| 88 | + | if startupElapsed > 500*time.Millisecond { | |
| 89 | + | log.Printf("[startup] WARNING: Startup took %v (target: <500ms)", startupElapsed) | |
| 90 | + | } | |
| 91 | + | ||
| 81 | 92 | app.Run() | |
| 82 | 93 | } | |
| 83 | 94 | ||
| @@ -136,6 +147,12 @@ func (app *BrowserApp) Initialize() error { | |||
| 136 | 147 | app.parserService = services.NewParserService(app.config, app.queries) | |
| 137 | 148 | app.browserService = services.NewBrowserService(app.config, app.queries) | |
| 138 | 149 | ||
| 150 | + | // Load all caches in parallel for fast startup (target: <100ms) | |
| 151 | + | if err := app.loadCachesParallel(context.Background()); err != nil { | |
| 152 | + | log.Printf("Warning: failed to load caches: %v", err) | |
| 153 | + | // Non-fatal - caches will fall back to defaults or DB queries | |
| 154 | + | } | |
| 155 | + | ||
| 139 | 156 | // Initialize handlers | |
| 140 | 157 | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | |
| 141 | 158 | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | |
| @@ -143,6 +160,57 @@ func (app *BrowserApp) Initialize() error { | |||
| 143 | 160 | return nil | |
| 144 | 161 | } | |
| 145 | 162 | ||
| 163 | + | // loadCachesParallel loads all caches in parallel for fast startup. | |
| 164 | + | // Uses errgroup to coordinate parallel loads and capture any errors. | |
| 165 | + | // Target: <100ms for all caches combined. | |
| 166 | + | func (app *BrowserApp) loadCachesParallel(ctx context.Context) error { | |
| 167 | + | startTime := time.Now() | |
| 168 | + | ||
| 169 | + | g, ctx := errgroup.WithContext(ctx) | |
| 170 | + | ||
| 171 | + | // Load zoom cache in parallel | |
| 172 | + | g.Go(func() error { | |
| 173 | + | if err := app.browserService.LoadZoomCacheFromDB(ctx); err != nil { | |
| 174 | + | log.Printf("[cache] Failed to load zoom cache: %v", err) | |
| 175 | + | return err | |
| 176 | + | } | |
| 177 | + | return nil | |
| 178 | + | }) | |
| 179 | + | ||
| 180 | + | // Load certificate validation cache in parallel | |
| 181 | + | g.Go(func() error { | |
| 182 | + | if err := app.browserService.LoadCertCacheFromDB(ctx); err != nil { | |
| 183 | + | log.Printf("[cache] Failed to load cert cache: %v", err) | |
| 184 | + | return err | |
| 185 | + | } | |
| 186 | + | return nil | |
| 187 | + | }) | |
| 188 | + | ||
| 189 | + | // Load fuzzy search cache in parallel for instant dmenu access | |
| 190 | + | g.Go(func() error { | |
| 191 | + | if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil { | |
| 192 | + | log.Printf("[cache] Failed to load fuzzy cache: %v", err) | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | return nil | |
| 196 | + | }) | |
| 197 | + | ||
| 198 | + | // Wait for all cache loads to complete | |
| 199 | + | if err := g.Wait(); err != nil { | |
| 200 | + | return fmt.Errorf("cache loading failed: %w", err) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | elapsed := time.Since(startTime) | |
| 204 | + | log.Printf("[cache] All caches loaded in %v", elapsed) | |
| 205 | + | ||
| 206 | + | // Warn if startup is slower than target | |
| 207 | + | if elapsed > 100*time.Millisecond { | |
| 208 | + | log.Printf("[cache] Warning: Cache loading took %v (target: <100ms)", elapsed) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | ||
| 146 | 214 | // Run executes the main browser loop | |
| 147 | 215 | func (app *BrowserApp) Run() { | |
| 148 | 216 | defer app.cleanup() | |
| @@ -211,11 +279,32 @@ func (app *BrowserApp) cleanup() { | |||
| 211 | 279 | app.workspace = nil | |
| 212 | 280 | } | |
| 213 | 281 | ||
| 214 | - | // Close database last | |
| 282 | + | // Flush all caches to ensure pending writes complete before database closes | |
| 283 | + | if app.browserService != nil { | |
| 284 | + | log.Printf("Flushing all caches...") | |
| 285 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 286 | + | defer cancel() | |
| 287 | + | if err := app.browserService.FlushAllCaches(ctx); err != nil { | |
| 288 | + | log.Printf("Warning: failed to flush caches: %v", err) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Close database with WAL checkpoint | |
| 215 | 293 | if app.database != nil { | |
| 216 | - | log.Printf("Closing database") | |
| 294 | + | log.Printf("Performing WAL checkpoint and closing database...") | |
| 295 | + | ||
| 296 | + | // Run WAL checkpoint to commit all pending writes and truncate WAL file | |
| 297 | + | if _, err := app.database.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { | |
| 298 | + | log.Printf("Warning: WAL checkpoint failed: %v", err) | |
| 299 | + | } else { | |
| 300 | + | log.Printf("WAL checkpoint completed successfully") | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // Close database connection | |
| 217 | 304 | if closeErr := app.database.Close(); closeErr != nil { | |
| 218 | 305 | log.Printf("Warning: failed to close database: %v", closeErr) | |
| 306 | + | } else { | |
| 307 | + | log.Printf("Database closed successfully") | |
| 219 | 308 | } | |
| 220 | 309 | } | |
| 221 | 310 | ||
| @@ -263,6 +352,20 @@ func (app *BrowserApp) runMainLoop() { | |||
| 263 | 352 | log.Printf("Entering GTK main loop…") | |
| 264 | 353 | webkit.RunMainLoop() | |
| 265 | 354 | log.Printf("GTK main loop exited") | |
| 355 | + | ||
| 356 | + | // Flush pending history writes immediately after main loop exit | |
| 357 | + | // This ensures database operations complete while GTK is still in a valid state | |
| 358 | + | // MUST happen before cleanup() deferred call, which happens after this function returns | |
| 359 | + | if app.browserService != nil { | |
| 360 | + | log.Printf("Flushing pending history writes...") | |
| 361 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 362 | + | defer cancel() | |
| 363 | + | if err := app.browserService.FlushHistoryQueue(ctx); err != nil { | |
| 364 | + | log.Printf("Warning: history queue flush incomplete: %v", err) | |
| 365 | + | } else { | |
| 366 | + | log.Printf("History queue flushed successfully") | |
| 367 | + | } | |
| 368 | + | } | |
| 266 | 369 | } else { | |
| 267 | 370 | log.Printf("Not entering GUI loop (non-CGO build)") | |
| 268 | 371 | // In non-CGO mode, just wait for signals | |
A internal/app/browser/constants.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package browser | |
| 2 | + | ||
| 3 | + | // Direction constants for pane navigation and splitting | |
| 4 | + | const ( | |
| 5 | + | DirectionLeft = "left" | |
| 6 | + | DirectionRight = "right" | |
| 7 | + | DirectionUp = "up" | |
| 8 | + | DirectionDown = "down" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // UI string constants | |
| 12 | + | const ( | |
| 13 | + | NewTabTitle = "New Tab" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Theme constants | |
| 17 | + | const ( | |
| 18 | + | ThemeDefault = "default" | |
| 19 | + | ) | |
M internal/app/browser/pane.go +0 -17
| @@ -104,23 +104,6 @@ func (p *BrowserPane) Cleanup() { | |||
| 104 | 104 | p.messageHandler.SetWebView(nil) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | - | // Notify JS to cleanup GUI | |
| 108 | - | if p.webView != nil && p.hasGUI && !p.webView.IsDestroyed() { | |
| 109 | - | cleanupScript := `try { | |
| 110 | - | if (typeof window.__dumber_teardown === 'function') { | |
| 111 | - | window.__dumber_teardown(); | |
| 112 | - | } | |
| 113 | - | if (window.__dumber_gui_manager) { | |
| 114 | - | window.__dumber_gui_manager.destroy(); | |
| 115 | - | } | |
| 116 | - | } catch (err) { | |
| 117 | - | console.warn('[dumber] GUI teardown failed', err); | |
| 118 | - | }` | |
| 119 | - | if err := p.webView.InjectScript(cleanupScript); err != nil { | |
| 120 | - | log.Printf("[pane-%s] failed to inject cleanup script: %v", p.id, err) | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | ||
| 124 | 107 | // Destroy WebView to unregister from global registry and release resources | |
| 125 | 108 | if p.webView != nil && !p.webView.IsDestroyed() { | |
| 126 | 109 | if err := p.webView.Destroy(); err != nil { | |
M internal/app/browser/shortcuts.go +2 -2
| @@ -67,7 +67,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||
| 67 | 67 | // by WindowShortcutHandler at the window level to prevent duplicates | |
| 68 | 68 | ||
| 69 | 69 | // Page refresh shortcuts | |
| 70 | - | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", 0, func() { | |
| 70 | + | _ = s.webView.RegisterKeyboardShortcut("ctrl+r", 0, func() { | |
| 71 | 71 | if !s.isActivePane() { | |
| 72 | 72 | return | |
| 73 | 73 | } | |
| @@ -75,7 +75,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||
| 75 | 75 | _ = s.webView.Reload() | |
| 76 | 76 | }) | |
| 77 | 77 | ||
| 78 | - | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", 0, func() { | |
| 78 | + | _ = s.webView.RegisterKeyboardShortcut("ctrl+shift+r", 0, func() { | |
| 79 | 79 | if !s.isActivePane() { | |
| 80 | 80 | return | |
| 81 | 81 | } | |
M internal/app/browser/stacked_panes.go +7 -5
| @@ -430,7 +430,7 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 430 | 430 | if pane.pane != nil && pane.pane.webView != nil { | |
| 431 | 431 | title := pane.pane.webView.GetTitle() | |
| 432 | 432 | if title == "" { | |
| 433 | - | title = "New Tab" | |
| 433 | + | title = NewTabTitle | |
| 434 | 434 | } | |
| 435 | 435 | spm.updateTitleBarLabel(pane, title) | |
| 436 | 436 | } | |
| @@ -475,12 +475,12 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 475 | 475 | // Calculate new index based on direction | |
| 476 | 476 | var newIndex int | |
| 477 | 477 | switch direction { | |
| 478 | - | case "up": | |
| 478 | + | case DirectionUp: | |
| 479 | 479 | newIndex = currentIndex - 1 | |
| 480 | 480 | if newIndex < 0 { | |
| 481 | 481 | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | |
| 482 | 482 | } | |
| 483 | - | case "down": | |
| 483 | + | case DirectionDown: | |
| 484 | 484 | newIndex = currentIndex + 1 | |
| 485 | 485 | if newIndex >= len(stackNode.stackedPanes) { | |
| 486 | 486 | newIndex = 0 // Wrap to first | |
| @@ -524,11 +524,11 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter { | |||
| 524 | 524 | if pane.pane != nil && pane.pane.webView != nil { | |
| 525 | 525 | titleText = pane.pane.webView.GetTitle() | |
| 526 | 526 | if titleText == "" { | |
| 527 | - | titleText = "New Tab" // Fallback only when title is actually empty | |
| 527 | + | titleText = NewTabTitle // Fallback only when title is actually empty | |
| 528 | 528 | } | |
| 529 | 529 | pageURL = pane.pane.webView.GetCurrentURL() | |
| 530 | 530 | } else { | |
| 531 | - | titleText = "New Tab" | |
| 531 | + | titleText = NewTabTitle | |
| 532 | 532 | } | |
| 533 | 533 | ||
| 534 | 534 | titleBar := spm.createTitleBarWithTitle(titleText, pageURL) | |
| @@ -714,6 +714,8 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||
| 714 | 714 | return titleBox | |
| 715 | 715 | } | |
| 716 | 716 | ||
| 717 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30) | |
| 718 | + | // | |
| 717 | 719 | // CloseStackedPane handles closing a pane that is part of a stack. | |
| 718 | 720 | // The logic mirrors the regular close path: we rebuild the tree, update focus, | |
| 719 | 721 | // and let WorkspaceManager perform the actual cleanup/destroy work so that all | |
M internal/app/browser/webview.go +13 -11
| @@ -43,17 +43,19 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) { | |||
| 43 | 43 | ||
| 44 | 44 | // Build WebKit configuration with data directories for persistence | |
| 45 | 45 | cfg := &webkit.Config{ | |
| 46 | - | UserAgent: buildUserAgent(app.config), | |
| 47 | - | EnableJavaScript: true, | |
| 48 | - | EnableWebGL: true, | |
| 49 | - | EnableMediaStream: true, | |
| 50 | - | HardwareAcceleration: true, | |
| 51 | - | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |
| 52 | - | MinimumFontSize: 8, | |
| 53 | - | DataDir: webkitData, | |
| 54 | - | CacheDir: webkitCache, | |
| 55 | - | AppearanceConfigJSON: app.buildAppearanceConfigJSON(), | |
| 56 | - | CreateWindow: true, // Default to creating a window for standalone WebViews | |
| 46 | + | UserAgent: buildUserAgent(app.config), | |
| 47 | + | EnableJavaScript: true, | |
| 48 | + | EnableWebGL: true, | |
| 49 | + | EnableMediaStream: true, | |
| 50 | + | HardwareAcceleration: true, | |
| 51 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |
| 52 | + | MinimumFontSize: 8, | |
| 53 | + | EnablePageCache: true, // Instant back/forward navigation (bfcache) | |
| 54 | + | EnableSmoothScrolling: true, // Smooth scrolling animations | |
| 55 | + | DataDir: webkitData, | |
| 56 | + | CacheDir: webkitCache, | |
| 57 | + | AppearanceConfigJSON: app.buildAppearanceConfigJSON(), | |
| 58 | + | CreateWindow: true, // Default to creating a window for standalone WebViews | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | return cfg, nil | |
M internal/app/browser/window_shortcuts.go +4 -4
| @@ -69,10 +69,10 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 69 | 69 | {"ctrl+minus", h.handleZoomOut, "Zoom out"}, | |
| 70 | 70 | {"ctrl+0", h.handleZoomReset, "Zoom reset"}, | |
| 71 | 71 | // Workspace navigation shortcuts - global level for proper active pane targeting | |
| 72 | - | {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation("left") }, "Navigate left pane"}, | |
| 73 | - | {"alt+ArrowRight", func() { h.handleWorkspaceNavigation("right") }, "Navigate right pane"}, | |
| 74 | - | {"alt+ArrowUp", func() { h.handleWorkspaceNavigation("up") }, "Navigate up pane"}, | |
| 75 | - | {"alt+ArrowDown", func() { h.handleWorkspaceNavigation("down") }, "Navigate down pane"}, | |
| 72 | + | {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane"}, | |
| 73 | + | {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"}, | |
| 74 | + | {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"}, | |
| 75 | + | {"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"}, | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | 78 | for _, shortcut := range shortcuts { | |
M internal/app/browser/workspace_css.go +3 -1
| @@ -185,7 +185,9 @@ func (wm *WorkspaceManager) ensureWorkspaceStyles() { | |||
| 185 | 185 | ||
| 186 | 186 | // Only apply CSS if it hasn't been applied globally or if content changed | |
| 187 | 187 | if !globalCSSInitialized || globalCSSContent != workspaceCSS { | |
| 188 | - | webkit.AddCSSProvider(workspaceCSS) | |
| 188 | + | if err := webkit.AddCSSProvider(workspaceCSS); err != nil { | |
| 189 | + | log.Printf("[workspace] WARNING: Failed to add CSS provider: %v", err) | |
| 190 | + | } | |
| 189 | 191 | globalCSSInitialized = true | |
| 190 | 192 | globalCSSContent = workspaceCSS | |
| 191 | 193 | log.Printf("[workspace] Applied workspace CSS styles (%d bytes)", len(workspaceCSS)) | |
M internal/app/browser/workspace_geometry_validator.go +2 -2
| @@ -166,9 +166,9 @@ func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) Val | |||
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | switch direction { | |
| 169 | - | case "left", "right": | |
| 169 | + | case DirectionLeft, DirectionRight: | |
| 170 | 170 | return gv.ValidateHorizontalSplit(node) | |
| 171 | - | case "up", "down": | |
| 171 | + | case DirectionUp, DirectionDown: | |
| 172 | 172 | return gv.ValidateVerticalSplit(node) | |
| 173 | 173 | default: | |
| 174 | 174 | return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)} | |
M internal/app/browser/workspace_manager.go +1 -1
| @@ -259,7 +259,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 259 | 259 | wm.SetActivePane(node, SourceProgrammatic) | |
| 260 | 260 | direction := strings.ToLower(msg.Direction) | |
| 261 | 261 | if direction == "" { | |
| 262 | - | direction = "right" | |
| 262 | + | direction = DirectionRight | |
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | 265 | if wm.shouldDebounce(source, 200*time.Millisecond) { | |
M internal/app/browser/workspace_navigation.go +23 -23
| @@ -105,21 +105,21 @@ func (wm *WorkspaceManager) detachFocus(node *paneNode) { | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | |
| 108 | - | // actual widget geometry to determine adjacency. For stacked panes, "up" and "down" | |
| 108 | + | // actual widget geometry to determine adjacency. For stacked panes, DirectionUp and DirectionDown | |
| 109 | 109 | // navigate within the stack. | |
| 110 | 110 | func (wm *WorkspaceManager) FocusNeighbor(direction string) bool { | |
| 111 | 111 | if wm == nil { | |
| 112 | 112 | return false | |
| 113 | 113 | } | |
| 114 | 114 | switch strings.ToLower(direction) { | |
| 115 | - | case "up", "down": | |
| 115 | + | case DirectionUp, DirectionDown: | |
| 116 | 116 | // Check if current pane is part of a stack and handle stack navigation | |
| 117 | 117 | if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) { | |
| 118 | 118 | return true | |
| 119 | 119 | } | |
| 120 | 120 | // Fall back to regular adjacency navigation | |
| 121 | 121 | return wm.focusAdjacent(strings.ToLower(direction)) | |
| 122 | - | case "left", "right": | |
| 122 | + | case DirectionLeft, DirectionRight: | |
| 123 | 123 | return wm.focusAdjacent(strings.ToLower(direction)) | |
| 124 | 124 | default: | |
| 125 | 125 | log.Printf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction) | |
| @@ -169,12 +169,12 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool { | |||
| 169 | 169 | // Calculate new index based on direction | |
| 170 | 170 | var newIndex int | |
| 171 | 171 | switch direction { | |
| 172 | - | case "up": | |
| 172 | + | case DirectionUp: | |
| 173 | 173 | newIndex = currentIndex - 1 | |
| 174 | 174 | if newIndex < 0 { | |
| 175 | 175 | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | |
| 176 | 176 | } | |
| 177 | - | case "down": | |
| 177 | + | case DirectionDown: | |
| 178 | 178 | newIndex = currentIndex + 1 | |
| 179 | 179 | if newIndex >= len(stackNode.stackedPanes) { | |
| 180 | 180 | newIndex = 0 // Wrap to first | |
| @@ -235,29 +235,29 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 235 | 235 | ||
| 236 | 236 | var score float64 | |
| 237 | 237 | switch direction { | |
| 238 | - | case "left": | |
| 238 | + | case DirectionLeft: | |
| 239 | 239 | if dx >= -focusEpsilon { | |
| 240 | 240 | continue | |
| 241 | 241 | } | |
| 242 | 242 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 243 | - | case "right": | |
| 243 | + | case DirectionRight: | |
| 244 | 244 | if dx <= focusEpsilon { | |
| 245 | 245 | continue | |
| 246 | 246 | } | |
| 247 | 247 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 248 | - | case "up": | |
| 248 | + | case DirectionUp: | |
| 249 | 249 | if dy >= -focusEpsilon { | |
| 250 | 250 | continue | |
| 251 | 251 | } | |
| 252 | 252 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 253 | - | case "down": | |
| 253 | + | case DirectionDown: | |
| 254 | 254 | if dy <= focusEpsilon { | |
| 255 | 255 | continue | |
| 256 | 256 | } | |
| 257 | 257 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | - | if direction == "up" || direction == "down" { | |
| 260 | + | if direction == DirectionUp || direction == DirectionDown { | |
| 261 | 261 | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score)) | |
| 262 | 262 | } | |
| 263 | 263 | ||
| @@ -287,29 +287,29 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) | |||
| 287 | 287 | refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container) | |
| 288 | 288 | cx := float64(refX) + float64(refWidth)/2.0 | |
| 289 | 289 | cy := float64(refY) + float64(refHeight)/2.0 | |
| 290 | - | axisVertical := direction == "up" || direction == "down" | |
| 290 | + | axisVertical := direction == DirectionUp || direction == DirectionDown | |
| 291 | 291 | ||
| 292 | 292 | for parent := node.parent; parent != nil; parent = parent.parent { | |
| 293 | 293 | switch direction { | |
| 294 | - | case "up": | |
| 294 | + | case DirectionUp: | |
| 295 | 295 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node { | |
| 296 | 296 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 297 | 297 | return leaf | |
| 298 | 298 | } | |
| 299 | 299 | } | |
| 300 | - | case "down": | |
| 300 | + | case DirectionDown: | |
| 301 | 301 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node { | |
| 302 | 302 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 303 | 303 | return leaf | |
| 304 | 304 | } | |
| 305 | 305 | } | |
| 306 | - | case "left": | |
| 306 | + | case DirectionLeft: | |
| 307 | 307 | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node { | |
| 308 | 308 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 309 | 309 | return leaf | |
| 310 | 310 | } | |
| 311 | 311 | } | |
| 312 | - | case "right": | |
| 312 | + | case DirectionRight: | |
| 313 | 313 | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node { | |
| 314 | 314 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 315 | 315 | return leaf | |
| @@ -338,22 +338,22 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6 | |||
| 338 | 338 | dy := ty - cy | |
| 339 | 339 | var score float64 | |
| 340 | 340 | switch direction { | |
| 341 | - | case "left": | |
| 341 | + | case DirectionLeft: | |
| 342 | 342 | if dx >= -focusEpsilon { | |
| 343 | 343 | continue | |
| 344 | 344 | } | |
| 345 | 345 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 346 | - | case "right": | |
| 346 | + | case DirectionRight: | |
| 347 | 347 | if dx <= focusEpsilon { | |
| 348 | 348 | continue | |
| 349 | 349 | } | |
| 350 | 350 | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 351 | - | case "up": | |
| 351 | + | case DirectionUp: | |
| 352 | 352 | if dy >= -focusEpsilon { | |
| 353 | 353 | continue | |
| 354 | 354 | } | |
| 355 | 355 | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 356 | - | case "down": | |
| 356 | + | case DirectionDown: | |
| 357 | 357 | if dy <= focusEpsilon { | |
| 358 | 358 | continue | |
| 359 | 359 | } | |
| @@ -393,22 +393,22 @@ func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction | |||
| 393 | 393 | return node | |
| 394 | 394 | } | |
| 395 | 395 | switch direction { | |
| 396 | - | case "up": | |
| 396 | + | case DirectionUp: | |
| 397 | 397 | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | |
| 398 | 398 | return leaf | |
| 399 | 399 | } | |
| 400 | 400 | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | |
| 401 | - | case "down": | |
| 401 | + | case DirectionDown: | |
| 402 | 402 | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | |
| 403 | 403 | return leaf | |
| 404 | 404 | } | |
| 405 | 405 | return wm.boundaryFallbackWithDepth(node.right, direction, depth+1) | |
| 406 | - | case "left": | |
| 406 | + | case DirectionLeft: | |
| 407 | 407 | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | |
| 408 | 408 | return leaf | |
| 409 | 409 | } | |
| 410 | 410 | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | |
| 411 | - | case "right": | |
| 411 | + | case DirectionRight: | |
| 412 | 412 | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | |
| 413 | 413 | return leaf | |
| 414 | 414 | } | |
M internal/app/browser/workspace_navigation_test.go +8 -8
| @@ -77,10 +77,10 @@ func TestMapDirection(t *testing.T) { | |||
| 77 | 77 | orientation gtk.Orientation | |
| 78 | 78 | existingFirst bool | |
| 79 | 79 | }{ | |
| 80 | - | {"down", gtk.OrientationVertical, true}, | |
| 81 | - | {"up", gtk.OrientationVertical, false}, | |
| 82 | - | {"right", gtk.OrientationHorizontal, true}, | |
| 83 | - | {"left", gtk.OrientationHorizontal, false}, | |
| 80 | + | {DirectionDown, gtk.OrientationVertical, true}, | |
| 81 | + | {DirectionUp, gtk.OrientationVertical, false}, | |
| 82 | + | {DirectionRight, gtk.OrientationHorizontal, true}, | |
| 83 | + | {DirectionLeft, gtk.OrientationHorizontal, false}, | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | 86 | for _, tt := range tests { | |
| @@ -131,10 +131,10 @@ func TestCaseSensitiveDirections(t *testing.T) { | |||
| 131 | 131 | "Down", | |
| 132 | 132 | "LEFT", | |
| 133 | 133 | "Right", | |
| 134 | - | "up", | |
| 135 | - | "down", | |
| 136 | - | "left", | |
| 137 | - | "right", | |
| 134 | + | DirectionUp, | |
| 135 | + | DirectionDown, | |
| 136 | + | DirectionLeft, | |
| 137 | + | DirectionRight, | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | 140 | for _, direction := range validDirections { | |
M internal/app/browser/workspace_operations.go +4 -4
| @@ -16,10 +16,10 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane | |||
| 16 | 16 | ||
| 17 | 17 | // Validate direction parameter | |
| 18 | 18 | validDirections := map[string]bool{ | |
| 19 | - | "left": true, | |
| 20 | - | "right": true, | |
| 21 | - | "up": true, | |
| 22 | - | "down": true, | |
| 19 | + | DirectionLeft: true, | |
| 20 | + | DirectionRight: true, | |
| 21 | + | DirectionUp: true, | |
| 22 | + | DirectionDown: true, | |
| 23 | 23 | } | |
| 24 | 24 | if !validDirections[direction] { | |
| 25 | 25 | return nil, fmt.Errorf("invalid split direction '%s', expected one of: left, right, up, down", direction) | |
M internal/app/browser/workspace_pane_mode.go +5 -5
| @@ -124,17 +124,17 @@ func (wm *WorkspaceManager) handlePaneSplit(node *paneNode, action string) { | |||
| 124 | 124 | return | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | - | // Extract direction from action (e.g., "split-right" -> "right") | |
| 127 | + | // Extract direction from action (e.g., "split-right" -> DirectionRight) | |
| 128 | 128 | var direction string | |
| 129 | 129 | switch action { | |
| 130 | 130 | case "split-right": | |
| 131 | - | direction = "right" | |
| 131 | + | direction = DirectionRight | |
| 132 | 132 | case "split-left": | |
| 133 | - | direction = "left" | |
| 133 | + | direction = DirectionLeft | |
| 134 | 134 | case "split-up": | |
| 135 | - | direction = "up" | |
| 135 | + | direction = DirectionUp | |
| 136 | 136 | case "split-down": | |
| 137 | - | direction = "down" | |
| 137 | + | direction = DirectionDown | |
| 138 | 138 | default: | |
| 139 | 139 | log.Printf("[pane-mode] Invalid split action: %s", action) | |
| 140 | 140 | return | |
M internal/app/browser/workspace_pane_ops.go +4 -0
| @@ -299,6 +299,8 @@ func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) { | |||
| 299 | 299 | // This eliminates widget reparenting of existing WebViews, which was causing rendering corruption. | |
| 300 | 300 | // See splitNode() for the unified implementation. | |
| 301 | 301 | ||
| 302 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (65 -> <30) | |
| 303 | + | // | |
| 302 | 304 | // splitNode splits a target pane into two panes in the specified direction | |
| 303 | 305 | // splitNode splits a target pane into two panes in the specified direction. | |
| 304 | 306 | // If existingPane is provided, it will be used instead of creating a new WebView. | |
| @@ -922,6 +924,8 @@ func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) { | |||
| 922 | 924 | } | |
| 923 | 925 | } | |
| 924 | 926 | ||
| 927 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (31 -> <30) | |
| 928 | + | // | |
| 925 | 929 | // closePane closes a specific pane and handles tree restructuring (simplified implementation) | |
| 926 | 930 | func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |
| 927 | 931 | ctx := wm.beginClose(node) | |
M internal/app/browser/workspace_popup.go +7 -7
| @@ -37,7 +37,7 @@ func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView | |||
| 37 | 37 | } | |
| 38 | 38 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 39 | 39 | if direction == "" { | |
| 40 | - | direction = "right" | |
| 40 | + | direction = DirectionRight | |
| 41 | 41 | } | |
| 42 | 42 | // Use splitNode with existing pane instead of insertPopupPane | |
| 43 | 43 | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | |
| @@ -65,7 +65,7 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||
| 65 | 65 | } | |
| 66 | 66 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 67 | 67 | if direction == "" { | |
| 68 | - | direction = "right" | |
| 68 | + | direction = DirectionRight | |
| 69 | 69 | } | |
| 70 | 70 | // Use splitNode with existing pane instead of insertPopupPane | |
| 71 | 71 | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | |
| @@ -427,7 +427,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 427 | 427 | // Split pane behavior (default) - use splitNode with existing popup pane | |
| 428 | 428 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 429 | 429 | if direction == "" { | |
| 430 | - | direction = "right" | |
| 430 | + | direction = DirectionRight | |
| 431 | 431 | } | |
| 432 | 432 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | |
| 433 | 433 | log.Printf("[workspace] Failed to insert split popup pane: %v", err) | |
| @@ -444,7 +444,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 444 | 444 | // Insert the popup pane in the same container - use splitNode with existing popup pane | |
| 445 | 445 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 446 | 446 | if direction == "" { | |
| 447 | - | direction = "right" | |
| 447 | + | direction = DirectionRight | |
| 448 | 448 | } | |
| 449 | 449 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | |
| 450 | 450 | log.Printf("[workspace] Failed to insert stacked popup pane: %v", err) | |
| @@ -456,7 +456,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 456 | 456 | log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split") | |
| 457 | 457 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 458 | 458 | if direction == "" { | |
| 459 | - | direction = "right" | |
| 459 | + | direction = DirectionRight | |
| 460 | 460 | } | |
| 461 | 461 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | |
| 462 | 462 | log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err) | |
| @@ -469,7 +469,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 469 | 469 | log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split") | |
| 470 | 470 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 471 | 471 | if direction == "" { | |
| 472 | - | direction = "right" | |
| 472 | + | direction = DirectionRight | |
| 473 | 473 | } | |
| 474 | 474 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | |
| 475 | 475 | log.Printf("[workspace] Failed to insert windowed popup pane: %v", err) | |
| @@ -481,7 +481,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||
| 481 | 481 | log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior) | |
| 482 | 482 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 483 | 483 | if direction == "" { | |
| 484 | - | direction = "right" | |
| 484 | + | direction = DirectionRight | |
| 485 | 485 | } | |
| 486 | 486 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | |
| 487 | 487 | log.Printf("[workspace] Failed to insert popup pane: %v", err) | |
M internal/app/browser/workspace_utils.go +3 -3
| @@ -35,11 +35,11 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | |||
| 35 | 35 | // mapDirection maps a direction string to GTK orientation and positioning | |
| 36 | 36 | func mapDirection(direction string) (gtk.Orientation, bool) { | |
| 37 | 37 | switch direction { | |
| 38 | - | case "left": | |
| 38 | + | case DirectionLeft: | |
| 39 | 39 | return gtk.OrientationHorizontal, false | |
| 40 | - | case "up": | |
| 40 | + | case DirectionUp: | |
| 41 | 41 | return gtk.OrientationVertical, false | |
| 42 | - | case "down": | |
| 42 | + | case DirectionDown: | |
| 43 | 43 | return gtk.OrientationVertical, true | |
| 44 | 44 | default: | |
| 45 | 45 | return gtk.OrientationHorizontal, true | |
M internal/app/control/navigation.go +6 -2
| @@ -55,7 +55,9 @@ func (n *NavigationController) NavigateToURL(input string) error { | |||
| 55 | 55 | haveZoom = true | |
| 56 | 56 | if n.webView != nil { | |
| 57 | 57 | if n.webView.UsesDomZoom() { | |
| 58 | - | n.webView.SeedDomZoom(zoomLevel) | |
| 58 | + | if err := n.webView.SeedDomZoom(zoomLevel); err != nil { | |
| 59 | + | log.Printf("Warning: failed to seed DOM zoom for %s: %v", result.URL, err) | |
| 60 | + | } | |
| 59 | 61 | } else { | |
| 60 | 62 | n.webView.RunOnMainThread(func() { | |
| 61 | 63 | if err := n.webView.SetZoom(zoomLevel); err != nil { | |
| @@ -108,7 +110,9 @@ func (n *NavigationController) HandleBrowseCommand() { | |||
| 108 | 110 | zoomLevel = level | |
| 109 | 111 | haveZoom = true | |
| 110 | 112 | if n.webView.UsesDomZoom() { | |
| 111 | - | n.webView.SeedDomZoom(zoomLevel) | |
| 113 | + | if err := n.webView.SeedDomZoom(zoomLevel); err != nil { | |
| 114 | + | log.Printf("Warning: failed to seed DOM zoom for %s: %v", result.URL, err) | |
| 115 | + | } | |
| 112 | 116 | } else { | |
| 113 | 117 | n.webView.RunOnMainThread(func() { | |
| 114 | 118 | if err := n.webView.SetZoom(zoomLevel); err != nil { | |
M internal/app/messaging/handler.go +6 -25
| @@ -82,9 +82,7 @@ func (h *Handler) Handle(payload string) { | |||
| 82 | 82 | case "navigate": | |
| 83 | 83 | h.handleNavigation(msg) | |
| 84 | 84 | case "query": | |
| 85 | - | log.Printf("[DEBUG] Handling query message: q=%s, limit=%d", msg.Q, msg.Limit) | |
| 86 | 85 | if h.webView == nil { | |
| 87 | - | log.Printf("[WARN] Dropping query response: webview unavailable for suggestions") | |
| 88 | 86 | return | |
| 89 | 87 | } | |
| 90 | 88 | h.handleQuery(msg) | |
| @@ -200,13 +198,7 @@ func (h *Handler) handleClosePopup(msg Message) { | |||
| 200 | 198 | } | |
| 201 | 199 | ||
| 202 | 200 | func (h *Handler) handleQuery(msg Message) { | |
| 203 | - | if h.webView == nil { | |
| 204 | - | log.Printf("[WARN] Skipping query handling: webview is nil") | |
| 205 | - | return | |
| 206 | - | } | |
| 207 | - | log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit) | |
| 208 | - | if h.browserService == nil { | |
| 209 | - | log.Printf("[ERROR] handleQuery: browserService is nil") | |
| 201 | + | if h.webView == nil || h.browserService == nil { | |
| 210 | 202 | return | |
| 211 | 203 | } | |
| 212 | 204 | ctx := context.Background() | |
| @@ -233,12 +225,8 @@ func (h *Handler) handleQuery(msg Message) { | |||
| 233 | 225 | // History | |
| 234 | 226 | if len(results) < limit { | |
| 235 | 227 | remaining := limit - len(results) | |
| 236 | - | log.Printf("[DEBUG] Searching history for '%s' with limit %d", q, remaining) | |
| 237 | - | if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err != nil { | |
| 238 | - | log.Printf("[ERROR] Failed to search history: %v", err) | |
| 239 | - | } else { | |
| 240 | - | log.Printf("[DEBUG] Found %d history entries", len(entries)) | |
| 241 | - | for i, e := range entries { | |
| 228 | + | if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err == nil { | |
| 229 | + | for _, e := range entries { | |
| 242 | 230 | // JSON roundtrip to map to get url field agnostic of struct tag | |
| 243 | 231 | bb, _ := json.Marshal(e) | |
| 244 | 232 | var m map[string]any | |
| @@ -249,7 +237,6 @@ func (h *Handler) handleQuery(msg Message) { | |||
| 249 | 237 | } else if s, ok := m["URL"].(string); ok { | |
| 250 | 238 | url = s | |
| 251 | 239 | } | |
| 252 | - | log.Printf("[DEBUG] History entry %d: url=%s", i, url) | |
| 253 | 240 | if url == "" { | |
| 254 | 241 | continue | |
| 255 | 242 | } | |
| @@ -271,17 +258,11 @@ func (h *Handler) handleQuery(msg Message) { | |||
| 271 | 258 | } | |
| 272 | 259 | ||
| 273 | 260 | // Inject back to GUI | |
| 274 | - | log.Printf("[DEBUG] Final results count: %d", len(results)) | |
| 275 | - | if b, err := json.Marshal(results); err != nil { | |
| 276 | - | log.Printf("[ERROR] Failed to marshal results: %v", err) | |
| 277 | - | } else { | |
| 261 | + | if b, err := json.Marshal(results); err == nil { | |
| 278 | 262 | // Prefer unified page-world API; fallback to legacy global function | |
| 279 | 263 | script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))" | |
| 280 | - | log.Print("[DEBUG] Injecting omnibox suggestions") | |
| 281 | - | if injErr := h.webView.InjectScript(script); injErr != nil { | |
| 282 | - | log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr) | |
| 283 | - | } else { | |
| 284 | - | log.Printf("[DEBUG] Successfully injected omnibox suggestions") | |
| 264 | + | if err := h.webView.InjectScript(script); err != nil { | |
| 265 | + | log.Printf("[ERROR] Failed to inject omnibox suggestions: %v", err) | |
| 285 | 266 | } | |
| 286 | 267 | } | |
| 287 | 268 | } | |
A internal/cache/cert_cache.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package cache | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/cache/generic" | |
| 7 | + | "github.com/bnema/dumber/internal/db" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // CertDBOperations implements DatabaseOperations for certificate validations cache. | |
| 11 | + | // Handles loading, persisting, and deleting certificate validations from the database. | |
| 12 | + | type CertDBOperations struct { | |
| 13 | + | queries db.DatabaseQuerier | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewCertDBOperations creates a new CertDBOperations instance. | |
| 17 | + | func NewCertDBOperations(queries db.DatabaseQuerier) *CertDBOperations { | |
| 18 | + | return &CertDBOperations{ | |
| 19 | + | queries: queries, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // LoadAll loads all certificate validations from the database. | |
| 24 | + | // Returns a map of hostname -> CertificateValidation. | |
| 25 | + | // Only loads non-expired validations. | |
| 26 | + | func (c *CertDBOperations) LoadAll(ctx context.Context) (map[string]db.CertificateValidation, error) { | |
| 27 | + | validations, err := c.queries.ListCertificateValidations(ctx) | |
| 28 | + | if err != nil { | |
| 29 | + | return nil, err | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | result := make(map[string]db.CertificateValidation, len(validations)) | |
| 33 | + | for _, validation := range validations { | |
| 34 | + | // Use hostname as key - most recent validation per hostname | |
| 35 | + | // (query already orders by created_at DESC in GetCertificateValidationByHostname) | |
| 36 | + | result[validation.Hostname] = validation | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return result, nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Persist saves a certificate validation to the database. | |
| 43 | + | // Uses INSERT OR REPLACE to handle both new and existing entries. | |
| 44 | + | func (c *CertDBOperations) Persist(ctx context.Context, hostname string, validation db.CertificateValidation) error { | |
| 45 | + | return c.queries.StoreCertificateValidation( | |
| 46 | + | ctx, | |
| 47 | + | hostname, | |
| 48 | + | validation.CertificateHash, | |
| 49 | + | validation.UserDecision, | |
| 50 | + | validation.ExpiresAt, | |
| 51 | + | ) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Delete removes a certificate validation from the database. | |
| 55 | + | func (c *CertDBOperations) Delete(ctx context.Context, hostname string) error { | |
| 56 | + | // Get the validation to find the certificate hash | |
| 57 | + | validation, err := c.queries.GetCertificateValidationByHostname(ctx, hostname) | |
| 58 | + | if err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | return c.queries.DeleteCertificateValidation(ctx, hostname, validation.CertificateHash) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // CertValidationsCache is a specialized cache for certificate validations. | |
| 66 | + | // It wraps GenericCache with hostname-specific helper methods. | |
| 67 | + | // Key insight: Caches hostname -> validation decision to avoid DB queries on HTTPS cert errors. | |
| 68 | + | type CertValidationsCache struct { | |
| 69 | + | *generic.GenericCache[string, db.CertificateValidation] | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // NewCertValidationsCache creates a new certificate validations cache. | |
| 73 | + | func NewCertValidationsCache(queries db.DatabaseQuerier) *CertValidationsCache { | |
| 74 | + | dbOps := NewCertDBOperations(queries) | |
| 75 | + | return &CertValidationsCache{ | |
| 76 | + | GenericCache: generic.NewGenericCache(dbOps), | |
| 77 | + | } | |
| 78 | + | } | |
M internal/cache/filter_cache.go +45 -13
| @@ -89,7 +89,11 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) { | |||
| 89 | 89 | if err != nil { | |
| 90 | 90 | return nil, fmt.Errorf("failed to open cache file: %w", err) | |
| 91 | 91 | } | |
| 92 | - | defer file.Close() | |
| 92 | + | defer func() { | |
| 93 | + | if err := file.Close(); err != nil { | |
| 94 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", err)) | |
| 95 | + | } | |
| 96 | + | }() | |
| 93 | 97 | ||
| 94 | 98 | // Get file size | |
| 95 | 99 | stat, err := file.Stat() | |
| @@ -135,7 +139,9 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) { | |||
| 135 | 139 | // Parse filter data without copying | |
| 136 | 140 | data, err := fc.parseInPlace(header) | |
| 137 | 141 | if err != nil { | |
| 138 | - | fc.Unmap() | |
| 142 | + | if unmapErr := fc.Unmap(); unmapErr != nil { | |
| 143 | + | logging.Warn(fmt.Sprintf("failed to unmap during error handling: %v", unmapErr)) | |
| 144 | + | } | |
| 139 | 145 | return nil, fmt.Errorf("failed to parse cache data: %w", err) | |
| 140 | 146 | } | |
| 141 | 147 | ||
| @@ -221,7 +227,11 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||
| 221 | 227 | if err != nil { | |
| 222 | 228 | return fmt.Errorf("failed to create cache file: %w", err) | |
| 223 | 229 | } | |
| 224 | - | defer file.Close() | |
| 230 | + | defer func() { | |
| 231 | + | if err := file.Close(); err != nil { | |
| 232 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", err)) | |
| 233 | + | } | |
| 234 | + | }() | |
| 225 | 235 | ||
| 226 | 236 | // Calculate total rule count | |
| 227 | 237 | totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules)) | |
| @@ -237,7 +247,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||
| 237 | 247 | } | |
| 238 | 248 | ||
| 239 | 249 | if err := binary.Write(file, binary.LittleEndian, header); err != nil { | |
| 240 | - | os.Remove(tmpPath) | |
| 250 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 251 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 252 | + | } | |
| 241 | 253 | return fmt.Errorf("failed to write header: %w", err) | |
| 242 | 254 | } | |
| 243 | 255 | ||
| @@ -246,7 +258,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||
| 246 | 258 | // Write network rules | |
| 247 | 259 | for _, rule := range data.NetworkRules { | |
| 248 | 260 | if err := fc.writeNetworkRule(file, rule); err != nil { | |
| 249 | - | os.Remove(tmpPath) | |
| 261 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 262 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 263 | + | } | |
| 250 | 264 | return fmt.Errorf("failed to write network rule: %w", err) | |
| 251 | 265 | } | |
| 252 | 266 | } | |
| @@ -254,7 +268,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||
| 254 | 268 | // Write cosmetic rules | |
| 255 | 269 | for _, rule := range data.CosmeticRules { | |
| 256 | 270 | if err := fc.writeCosmeticRule(file, rule); err != nil { | |
| 257 | - | os.Remove(tmpPath) | |
| 271 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 272 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 273 | + | } | |
| 258 | 274 | return fmt.Errorf("failed to write cosmetic rule: %w", err) | |
| 259 | 275 | } | |
| 260 | 276 | } | |
| @@ -265,7 +281,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||
| 265 | 281 | ||
| 266 | 282 | // Update header with data size | |
| 267 | 283 | if _, err := file.Seek(0, io.SeekStart); err != nil { | |
| 268 | - | os.Remove(tmpPath) | |
| 284 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 285 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 286 | + | } | |
| 269 | 287 | return err | |
| 270 | 288 | } | |
| 271 | 289 | ||
| @@ -273,20 +291,28 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||
| 273 | 291 | // TODO: Calculate and set checksum | |
| 274 | 292 | ||
| 275 | 293 | if err := binary.Write(file, binary.LittleEndian, header); err != nil { | |
| 276 | - | os.Remove(tmpPath) | |
| 294 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 295 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 296 | + | } | |
| 277 | 297 | return fmt.Errorf("failed to update header: %w", err) | |
| 278 | 298 | } | |
| 279 | 299 | ||
| 280 | 300 | if err := file.Sync(); err != nil { | |
| 281 | - | os.Remove(tmpPath) | |
| 301 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 302 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 303 | + | } | |
| 282 | 304 | return fmt.Errorf("failed to sync cache file: %w", err) | |
| 283 | 305 | } | |
| 284 | 306 | ||
| 285 | - | file.Close() | |
| 307 | + | if err := file.Close(); err != nil { | |
| 308 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", err)) | |
| 309 | + | } | |
| 286 | 310 | ||
| 287 | 311 | // Atomic rename | |
| 288 | 312 | if err := os.Rename(tmpPath, fc.path); err != nil { | |
| 289 | - | os.Remove(tmpPath) | |
| 313 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { | |
| 314 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |
| 315 | + | } | |
| 290 | 316 | return fmt.Errorf("failed to rename cache file: %w", err) | |
| 291 | 317 | } | |
| 292 | 318 | ||
| @@ -382,7 +408,11 @@ func (fc *FilterCache) IsValid() bool { | |||
| 382 | 408 | if err != nil { | |
| 383 | 409 | return false | |
| 384 | 410 | } | |
| 385 | - | defer file.Close() | |
| 411 | + | defer func() { | |
| 412 | + | if err := file.Close(); err != nil { | |
| 413 | + | logging.Warn(fmt.Sprintf("failed to close file during validation: %v", err)) | |
| 414 | + | } | |
| 415 | + | }() | |
| 386 | 416 | ||
| 387 | 417 | var magic uint64 | |
| 388 | 418 | if err := binary.Read(file, binary.LittleEndian, &magic); err != nil { | |
| @@ -394,6 +424,8 @@ func (fc *FilterCache) IsValid() bool { | |||
| 394 | 424 | ||
| 395 | 425 | // Remove deletes the cache file | |
| 396 | 426 | func (fc *FilterCache) Remove() error { | |
| 397 | - | fc.Unmap() // Unmap first if mapped | |
| 427 | + | if err := fc.Unmap(); err != nil { | |
| 428 | + | logging.Warn(fmt.Sprintf("failed to unmap file during removal: %v", err)) | |
| 429 | + | } | |
| 398 | 430 | return os.Remove(fc.path) | |
| 399 | 431 | } | |
M internal/cache/fuzzy_cache.go +2 -2
| @@ -2,7 +2,7 @@ package cache | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "crypto/md5" | |
| 5 | + | "crypto/sha256" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| @@ -286,7 +286,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) { | |||
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | 288 | // Create hash from URLs, visit counts, and last visited times | |
| 289 | - | hasher := md5.New() | |
| 289 | + | hasher := sha256.New() | |
| 290 | 290 | for _, entry := range recentHistory { | |
| 291 | 291 | hasher.Write([]byte(entry.Url)) | |
| 292 | 292 | if entry.VisitCount.Valid { | |
M internal/cache/fuzzy_cache_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | mock_cache "github.com/bnema/dumber/internal/cache/mocks" | |
| 12 | 12 | "github.com/bnema/dumber/internal/db" | |
| 13 | - | "github.com/golang/mock/gomock" | |
| 13 | + | "go.uber.org/mock/gomock" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | func createTestHistory() []db.History { | |
A internal/cache/generic/README.md +244 -0
| @@ -0,0 +1,244 @@ | |||
| 1 | + | # Generic Cache Framework | |
| 2 | + | ||
| 3 | + | A type-safe, high-performance caching layer for Dumber Browser that provides RAM-first access with asynchronous database persistence. | |
| 4 | + | ||
| 5 | + | ## Overview | |
| 6 | + | ||
| 7 | + | The generic cache framework provides a consistent caching pattern across all Dumber Browser caches: | |
| 8 | + | - **RAM-First**: All reads from memory (no DB queries) | |
| 9 | + | - **Async Writes**: Updates happen immediately in cache, persisted asynchronously | |
| 10 | + | - **Bulk Load**: Load all data from DB at startup | |
| 11 | + | - **Graceful Shutdown**: Flush all pending writes on shutdown | |
| 12 | + | ||
| 13 | + | ## Architecture | |
| 14 | + | ||
| 15 | + | ``` | |
| 16 | + | ┌─────────────────────────────────────────────────────────────┐ | |
| 17 | + | │ Application Code │ | |
| 18 | + | │ (BrowserService, etc.) │ | |
| 19 | + | └──────────────────────────┬──────────────────────────────────┘ | |
| 20 | + | │ | |
| 21 | + | ▼ | |
| 22 | + | ┌─────────────────────────────────────────────────────────────┐ | |
| 23 | + | │ Cache[K, V] Interface │ | |
| 24 | + | │ │ | |
| 25 | + | │ Load(ctx) - Bulk load at startup │ | |
| 26 | + | │ Get(key) - RAM lookup only (never DB) │ | |
| 27 | + | │ Set(key, value) - Immediate cache + async DB │ | |
| 28 | + | │ Delete(key) - Immediate cache + async DB │ | |
| 29 | + | │ List() - All cached values │ | |
| 30 | + | │ Flush() - Wait for pending writes │ | |
| 31 | + | └──────────────────────────┬──────────────────────────────────┘ | |
| 32 | + | │ | |
| 33 | + | ▼ | |
| 34 | + | ┌─────────────────────────────────────────────────────────────┐ | |
| 35 | + | │ GenericCache[K, V] Implementation │ | |
| 36 | + | │ │ | |
| 37 | + | │ ┌──────────────┐ ┌──────────────────────────────────┐ │ | |
| 38 | + | │ │ sync.Map │ │ DatabaseOperations[K, V] │ │ | |
| 39 | + | │ │ (RAM cache) │ │ │ │ | |
| 40 | + | │ │ │ │ - LoadAll(ctx) → map[K]V │ │ | |
| 41 | + | │ │ │ │ - Persist(ctx, K, V) → error │ │ | |
| 42 | + | │ │ │ │ - Delete(ctx, K) → error │ │ | |
| 43 | + | │ └──────────────┘ └──────────────────────────────────┘ │ | |
| 44 | + | │ │ | |
| 45 | + | │ ┌────────────────────────────────────────────────────────┐ │ | |
| 46 | + | │ │ pendingWrites sync.WaitGroup │ │ | |
| 47 | + | │ │ (tracks async operations for graceful shutdown) │ │ | |
| 48 | + | │ └────────────────────────────────────────────────────────┘ │ | |
| 49 | + | └──────────────────────────┬──────────────────────────────────┘ | |
| 50 | + | │ | |
| 51 | + | ▼ | |
| 52 | + | ┌─────────────────────────────────────────────────────────────┐ | |
| 53 | + | │ Database (SQLite) │ | |
| 54 | + | │ │ | |
| 55 | + | │ zoom_levels, shortcuts, certificate_validations, etc. │ | |
| 56 | + | └─────────────────────────────────────────────────────────────┘ | |
| 57 | + | ``` | |
| 58 | + | ||
| 59 | + | ## Usage Example | |
| 60 | + | ||
| 61 | + | ### 1. Implement DatabaseOperations | |
| 62 | + | ||
| 63 | + | First, create a struct that implements the `DatabaseOperations[K, V]` interface: | |
| 64 | + | ||
| 65 | + | ```go | |
| 66 | + | package cache | |
| 67 | + | ||
| 68 | + | import ( | |
| 69 | + | "context" | |
| 70 | + | "github.com/bnema/dumber/internal/cache/generic" | |
| 71 | + | "github.com/bnema/dumber/internal/db" | |
| 72 | + | ) | |
| 73 | + | ||
| 74 | + | // ZoomDBOperations implements DatabaseOperations for zoom levels | |
| 75 | + | type ZoomDBOperations struct { | |
| 76 | + | queries db.DatabaseQuerier | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations { | |
| 80 | + | return &ZoomDBOperations{queries: queries} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) { | |
| 84 | + | levels, err := z.queries.ListZoomLevels(ctx) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | result := make(map[string]float64, len(levels)) | |
| 90 | + | for _, level := range levels { | |
| 91 | + | result[level.Domain] = level.ZoomFactor | |
| 92 | + | } | |
| 93 | + | return result, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (z *ZoomDBOperations) Persist(ctx context.Context, key string, value float64) error { | |
| 97 | + | return z.queries.SetZoomLevel(ctx, key, value) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (z *ZoomDBOperations) Delete(ctx context.Context, key string) error { | |
| 101 | + | return z.queries.DeleteZoomLevel(ctx, key) | |
| 102 | + | } | |
| 103 | + | ``` | |
| 104 | + | ||
| 105 | + | ### 2. Create a Cache Instance | |
| 106 | + | ||
| 107 | + | ```go | |
| 108 | + | package cache | |
| 109 | + | ||
| 110 | + | import "github.com/bnema/dumber/internal/cache/generic" | |
| 111 | + | ||
| 112 | + | type ZoomCache struct { | |
| 113 | + | *generic.GenericCache[string, float64] | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache { | |
| 117 | + | dbOps := NewZoomDBOperations(queries) | |
| 118 | + | return &ZoomCache{ | |
| 119 | + | GenericCache: generic.NewGenericCache(dbOps), | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ``` | |
| 123 | + | ||
| 124 | + | ### 3. Use in Application Code | |
| 125 | + | ||
| 126 | + | ```go | |
| 127 | + | // At startup | |
| 128 | + | zoomCache := cache.NewZoomCache(dbQueries) | |
| 129 | + | if err := zoomCache.Load(ctx); err != nil { | |
| 130 | + | log.Fatalf("Failed to load zoom cache: %v", err) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Get (always from RAM, never hits DB) | |
| 134 | + | if zoomLevel, ok := zoomCache.Get("example.com"); ok { | |
| 135 | + | fmt.Printf("Zoom level: %.2f\n", zoomLevel) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Set (immediate in cache, async to DB) | |
| 139 | + | if err := zoomCache.Set("example.com", 1.5); err != nil { | |
| 140 | + | log.Printf("Failed to set zoom level: %v", err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // On shutdown | |
| 144 | + | if err := zoomCache.Flush(); err != nil { | |
| 145 | + | log.Printf("Failed to flush cache: %v", err) | |
| 146 | + | } | |
| 147 | + | ``` | |
| 148 | + | ||
| 149 | + | ## Performance Characteristics | |
| 150 | + | ||
| 151 | + | | Operation | Latency | Notes | | |
| 152 | + | |-----------|-------------|--------------------------------------------| | |
| 153 | + | | Load() | ~2-50ms | One-time at startup, bulk loads all data | | |
| 154 | + | | Get() | <1µs | sync.Map lookup, never touches DB | | |
| 155 | + | | Set() | <1µs | Returns immediately, DB write async | | |
| 156 | + | | Delete() | <1µs | Returns immediately, DB delete async | | |
| 157 | + | | List() | ~10-100µs | Iterates sync.Map, no DB access | | |
| 158 | + | | Flush() | Varies | Blocks until all async writes complete | | |
| 159 | + | ||
| 160 | + | ## Thread Safety | |
| 161 | + | ||
| 162 | + | All operations are thread-safe: | |
| 163 | + | - `sync.Map` provides lock-free concurrent reads and writes | |
| 164 | + | - `sync.WaitGroup` coordinates graceful shutdown | |
| 165 | + | - Database operations are serialized per key (last write wins) | |
| 166 | + | ||
| 167 | + | ## Testing | |
| 168 | + | ||
| 169 | + | The framework includes comprehensive unit tests with a mock implementation: | |
| 170 | + | ||
| 171 | + | ```go | |
| 172 | + | import "github.com/bnema/dumber/internal/cache/generic" | |
| 173 | + | ||
| 174 | + | func TestMyCache(t *testing.T) { | |
| 175 | + | mock := generic.NewMockDatabaseOperations[string, int]() | |
| 176 | + | ||
| 177 | + | // Configure mock behavior | |
| 178 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |
| 179 | + | return map[string]int{"key": 42}, nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | cache := generic.NewGenericCache(mock) | |
| 183 | + | ||
| 184 | + | // Test cache operations | |
| 185 | + | if err := cache.Load(context.Background()); err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | val, ok := cache.Get("key") | |
| 190 | + | if !ok || val != 42 { | |
| 191 | + | t.Errorf("Expected key=42, got %v, %v", val, ok) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Verify mock was called | |
| 195 | + | if count := mock.GetLoadAllCallCount(); count != 1 { | |
| 196 | + | t.Errorf("Expected LoadAll called once, got %d", count) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ``` | |
| 200 | + | ||
| 201 | + | ## Design Decisions | |
| 202 | + | ||
| 203 | + | ### Why Generic? | |
| 204 | + | ||
| 205 | + | Type safety and code reuse. The same cache implementation works for: | |
| 206 | + | - `Cache[string, float64]` (zoom levels) | |
| 207 | + | - `Cache[string, Shortcut]` (search shortcuts) | |
| 208 | + | - `Cache[string, CertValidation]` (TLS certificates) | |
| 209 | + | ||
| 210 | + | ### Why Async Persistence? | |
| 211 | + | ||
| 212 | + | - **Startup Performance**: <500ms startup time requirement | |
| 213 | + | - **UI Responsiveness**: Never block user actions on DB writes | |
| 214 | + | - **Write Coalescing**: Multiple rapid updates to same key only persist latest value | |
| 215 | + | ||
| 216 | + | ### Why Bulk Load? | |
| 217 | + | ||
| 218 | + | - Small datasets (10-100 entries typical) | |
| 219 | + | - Faster than incremental loading with query overhead | |
| 220 | + | - Simpler code: no cache miss logic needed | |
| 221 | + | ||
| 222 | + | ### Why sync.Map Instead of map + RWMutex? | |
| 223 | + | ||
| 224 | + | - Lock-free reads in common case | |
| 225 | + | - Better performance under concurrent load | |
| 226 | + | - Standard library, battle-tested | |
| 227 | + | ||
| 228 | + | ## Limitations | |
| 229 | + | ||
| 230 | + | - **Not for large datasets**: Loads entire dataset into RAM | |
| 231 | + | - **No TTL/expiration**: All entries cached indefinitely | |
| 232 | + | - **No size limits**: Assumes datasets fit comfortably in memory | |
| 233 | + | - **Last write wins**: No conflict resolution for concurrent updates | |
| 234 | + | ||
| 235 | + | For Dumber Browser's use case (small configuration tables), these tradeoffs are acceptable. | |
| 236 | + | ||
| 237 | + | ## Future Enhancements | |
| 238 | + | ||
| 239 | + | Potential improvements if needed: | |
| 240 | + | - [ ] Add metrics (cache hits, miss rate, persist latency) | |
| 241 | + | - [ ] Add TTL support for time-sensitive data | |
| 242 | + | - [ ] Add size-based eviction for large caches | |
| 243 | + | - [ ] Add batch persistence for write-heavy workloads | |
| 244 | + | - [ ] Add versioning/CAS for concurrent update detection | |
A internal/cache/generic/cache.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package generic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "log" | |
| 6 | + | "sync" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Cache provides a generic interface for caching any type of data. | |
| 10 | + | // K is the key type (must be comparable), V is the value type. | |
| 11 | + | // All operations are thread-safe via sync.Map. | |
| 12 | + | // | |
| 13 | + | // Design principles: | |
| 14 | + | // - RAM-first: All reads from memory (no DB queries) | |
| 15 | + | // - Async writes: Updates happen immediately in cache, persisted asynchronously | |
| 16 | + | // - Bulk load: Load all data from DB at startup | |
| 17 | + | // - Flush on shutdown: Ensure all pending writes complete gracefully | |
| 18 | + | type Cache[K comparable, V any] interface { | |
| 19 | + | // Load bulk-loads all data from storage into memory at startup | |
| 20 | + | Load(ctx context.Context) error | |
| 21 | + | ||
| 22 | + | // Get retrieves a value from the cache (RAM only, never queries DB) | |
| 23 | + | Get(key K) (V, bool) | |
| 24 | + | ||
| 25 | + | // Set updates the cache immediately and persists to DB asynchronously | |
| 26 | + | Set(key K, value V) error | |
| 27 | + | ||
| 28 | + | // Delete removes from cache immediately and persists deletion asynchronously | |
| 29 | + | Delete(key K) error | |
| 30 | + | ||
| 31 | + | // List returns all cached values as a slice | |
| 32 | + | List() []V | |
| 33 | + | ||
| 34 | + | // Flush waits for all pending async writes to complete (call on shutdown) | |
| 35 | + | Flush() error | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // DatabaseOperations defines the interface for database operations required by the cache. | |
| 39 | + | // This interface allows for easy mocking in tests using gomock. | |
| 40 | + | type DatabaseOperations[K comparable, V any] interface { | |
| 41 | + | // LoadAll loads all entries from the database | |
| 42 | + | LoadAll(ctx context.Context) (map[K]V, error) | |
| 43 | + | ||
| 44 | + | // Persist saves a single entry to the database | |
| 45 | + | Persist(ctx context.Context, key K, value V) error | |
| 46 | + | ||
| 47 | + | // Delete removes a single entry from the database | |
| 48 | + | Delete(ctx context.Context, key K) error | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // GenericCache implements Cache[K, V] using sync.Map for thread-safe storage | |
| 52 | + | // and a DatabaseOperations interface for database operations. | |
| 53 | + | type GenericCache[K comparable, V any] struct { | |
| 54 | + | cache sync.Map | |
| 55 | + | dbOps DatabaseOperations[K, V] | |
| 56 | + | ||
| 57 | + | // Track pending async operations for graceful shutdown | |
| 58 | + | pendingWrites sync.WaitGroup | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // NewGenericCache creates a new cache with the provided database operations. | |
| 62 | + | // | |
| 63 | + | // Parameters: | |
| 64 | + | // - dbOps: Implementation of DatabaseOperations for loading, persisting, and deleting data | |
| 65 | + | func NewGenericCache[K comparable, V any]( | |
| 66 | + | dbOps DatabaseOperations[K, V], | |
| 67 | + | ) *GenericCache[K, V] { | |
| 68 | + | return &GenericCache[K, V]{ | |
| 69 | + | dbOps: dbOps, | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Load bulk-loads all data from the database into memory. | |
| 74 | + | // Should be called once at application startup. | |
| 75 | + | func (c *GenericCache[K, V]) Load(ctx context.Context) error { | |
| 76 | + | data, err := c.dbOps.LoadAll(ctx) | |
| 77 | + | if err != nil { | |
| 78 | + | return err | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | for k, v := range data { | |
| 82 | + | c.cache.Store(k, v) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Get retrieves a value from the cache. Returns (value, true) if found, | |
| 89 | + | // or (zero value, false) if not found. Never queries the database. | |
| 90 | + | func (c *GenericCache[K, V]) Get(key K) (V, bool) { | |
| 91 | + | val, ok := c.cache.Load(key) | |
| 92 | + | if !ok { | |
| 93 | + | var zero V | |
| 94 | + | return zero, false | |
| 95 | + | } | |
| 96 | + | return val.(V), true | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Set updates the cache immediately (synchronous) and persists to the database | |
| 100 | + | // asynchronously (non-blocking). Returns immediately without waiting for DB write. | |
| 101 | + | func (c *GenericCache[K, V]) Set(key K, value V) error { | |
| 102 | + | // Update cache immediately (sync, fast) | |
| 103 | + | c.cache.Store(key, value) | |
| 104 | + | ||
| 105 | + | // Persist to DB asynchronously (non-blocking) | |
| 106 | + | c.pendingWrites.Add(1) | |
| 107 | + | go func() { | |
| 108 | + | defer c.pendingWrites.Done() | |
| 109 | + | ||
| 110 | + | ctx := context.Background() | |
| 111 | + | if err := c.dbOps.Persist(ctx, key, value); err != nil { | |
| 112 | + | log.Printf("Warning: async persist failed for key %v: %v", key, err) | |
| 113 | + | } | |
| 114 | + | }() | |
| 115 | + | ||
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Delete removes from cache immediately (synchronous) and persists deletion | |
| 120 | + | // to the database asynchronously (non-blocking). | |
| 121 | + | func (c *GenericCache[K, V]) Delete(key K) error { | |
| 122 | + | // Remove from cache immediately (sync, fast) | |
| 123 | + | c.cache.Delete(key) | |
| 124 | + | ||
| 125 | + | // Persist deletion to DB asynchronously (non-blocking) | |
| 126 | + | c.pendingWrites.Add(1) | |
| 127 | + | go func() { | |
| 128 | + | defer c.pendingWrites.Done() | |
| 129 | + | ||
| 130 | + | ctx := context.Background() | |
| 131 | + | if err := c.dbOps.Delete(ctx, key); err != nil { | |
| 132 | + | log.Printf("Warning: async delete failed for key %v: %v", key, err) | |
| 133 | + | } | |
| 134 | + | }() | |
| 135 | + | ||
| 136 | + | return nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // List returns all values currently in the cache as a slice. | |
| 140 | + | // The order is not guaranteed. | |
| 141 | + | func (c *GenericCache[K, V]) List() []V { | |
| 142 | + | var values []V | |
| 143 | + | ||
| 144 | + | c.cache.Range(func(key, value interface{}) bool { | |
| 145 | + | values = append(values, value.(V)) | |
| 146 | + | return true | |
| 147 | + | }) | |
| 148 | + | ||
| 149 | + | return values | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Flush blocks until all pending async writes complete. | |
| 153 | + | // Should be called during graceful shutdown to ensure no data loss. | |
| 154 | + | func (c *GenericCache[K, V]) Flush() error { | |
| 155 | + | c.pendingWrites.Wait() | |
| 156 | + | return nil | |
| 157 | + | } | |
A internal/cache/generic/cache_test.go +461 -0
| @@ -0,0 +1,461 @@ | |||
| 1 | + | package generic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // TestLoad verifies that Load correctly populates the cache from the database | |
| 12 | + | func TestLoad(t *testing.T) { | |
| 13 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 14 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |
| 15 | + | return map[string]int{ | |
| 16 | + | "one": 1, | |
| 17 | + | "two": 2, | |
| 18 | + | "three": 3, | |
| 19 | + | }, nil | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | cache := NewGenericCache(mock) | |
| 23 | + | ||
| 24 | + | err := cache.Load(context.Background()) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatalf("Load() failed: %v", err) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Verify LoadAll was called once | |
| 30 | + | if count := mock.GetLoadAllCallCount(); count != 1 { | |
| 31 | + | t.Errorf("Expected LoadAll to be called once, got %d", count) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Verify all entries were loaded into cache | |
| 35 | + | if val, ok := cache.Get("one"); !ok || val != 1 { | |
| 36 | + | t.Errorf("Expected one=1, got %v, %v", val, ok) | |
| 37 | + | } | |
| 38 | + | if val, ok := cache.Get("two"); !ok || val != 2 { | |
| 39 | + | t.Errorf("Expected two=2, got %v, %v", val, ok) | |
| 40 | + | } | |
| 41 | + | if val, ok := cache.Get("three"); !ok || val != 3 { | |
| 42 | + | t.Errorf("Expected three=3, got %v, %v", val, ok) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // TestLoadError verifies that Load propagates errors from the database | |
| 47 | + | func TestLoadError(t *testing.T) { | |
| 48 | + | expectedErr := errors.New("database connection failed") | |
| 49 | + | ||
| 50 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 51 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |
| 52 | + | return nil, expectedErr | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | cache := NewGenericCache(mock) | |
| 56 | + | ||
| 57 | + | err := cache.Load(context.Background()) | |
| 58 | + | if !errors.Is(err, expectedErr) { | |
| 59 | + | t.Errorf("Expected error %v, got %v", expectedErr, err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Verify LoadAll was called | |
| 63 | + | if count := mock.GetLoadAllCallCount(); count != 1 { | |
| 64 | + | t.Errorf("Expected LoadAll to be called once, got %d", count) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // TestGetNotFound verifies that Get returns false for non-existent keys | |
| 69 | + | func TestGetNotFound(t *testing.T) { | |
| 70 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 71 | + | cache := NewGenericCache(mock) | |
| 72 | + | ||
| 73 | + | if err := cache.Load(context.Background()); err != nil { | |
| 74 | + | t.Fatalf("Load() failed: %v", err) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | val, ok := cache.Get("nonexistent") | |
| 78 | + | if ok { | |
| 79 | + | t.Errorf("Expected Get to return false for nonexistent key, got %v", val) | |
| 80 | + | } | |
| 81 | + | if val != 0 { | |
| 82 | + | t.Errorf("Expected zero value, got %v", val) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // TestSet verifies that Set immediately updates the cache and persists asynchronously | |
| 87 | + | func TestSet(t *testing.T) { | |
| 88 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 89 | + | ||
| 90 | + | cache := NewGenericCache(mock) | |
| 91 | + | if err := cache.Load(context.Background()); err != nil { | |
| 92 | + | t.Fatalf("Load() failed: %v", err) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Set should return immediately | |
| 96 | + | err := cache.Set("test", 42) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatalf("Set() failed: %v", err) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Value should be in cache immediately | |
| 102 | + | val, ok := cache.Get("test") | |
| 103 | + | if !ok || val != 42 { | |
| 104 | + | t.Errorf("Expected test=42 immediately after Set, got %v, %v", val, ok) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Wait for async persist to complete | |
| 108 | + | if err := cache.Flush(); err != nil { | |
| 109 | + | t.Fatalf("Flush() failed: %v", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Verify Persist was called | |
| 113 | + | if count := mock.GetPersistCallCount(); count != 1 { | |
| 114 | + | t.Errorf("Expected Persist to be called once, got %d", count) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Verify correct parameters | |
| 118 | + | lastCall := mock.GetLastPersistCall() | |
| 119 | + | if lastCall == nil { | |
| 120 | + | t.Fatal("Expected Persist to be called") | |
| 121 | + | } | |
| 122 | + | if lastCall.Key != "test" || lastCall.Value != 42 { | |
| 123 | + | t.Errorf("Expected Persist(test, 42), got Persist(%v, %v)", lastCall.Key, lastCall.Value) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // TestSetAsyncPersist verifies that Set doesn't block on slow database writes | |
| 128 | + | func TestSetAsyncPersist(t *testing.T) { | |
| 129 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 130 | + | slowPersist := make(chan struct{}) | |
| 131 | + | ||
| 132 | + | mock.PersistFunc = func(ctx context.Context, key string, value int) error { | |
| 133 | + | <-slowPersist // Block until we signal | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | cache := NewGenericCache(mock) | |
| 138 | + | if err := cache.Load(context.Background()); err != nil { | |
| 139 | + | t.Fatalf("Load() failed: %v", err) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Set should return immediately even though persist is blocked | |
| 143 | + | done := make(chan struct{}) | |
| 144 | + | go func() { | |
| 145 | + | if err := cache.Set("test", 42); err != nil { | |
| 146 | + | t.Errorf("Set() failed: %v", err) | |
| 147 | + | } | |
| 148 | + | close(done) | |
| 149 | + | }() | |
| 150 | + | ||
| 151 | + | select { | |
| 152 | + | case <-done: | |
| 153 | + | // Good, Set returned immediately | |
| 154 | + | case <-time.After(100 * time.Millisecond): | |
| 155 | + | t.Error("Set() blocked for too long") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // Verify value is in cache even though persist hasn't completed | |
| 159 | + | val, ok := cache.Get("test") | |
| 160 | + | if !ok || val != 42 { | |
| 161 | + | t.Errorf("Expected test=42 in cache before persist completes, got %v, %v", val, ok) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Now allow persist to complete | |
| 165 | + | close(slowPersist) | |
| 166 | + | if err := cache.Flush(); err != nil { | |
| 167 | + | t.Fatalf("Flush() failed: %v", err) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Verify Persist was called | |
| 171 | + | if count := mock.GetPersistCallCount(); count != 1 { | |
| 172 | + | t.Errorf("Expected Persist to be called once, got %d", count) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // TestDelete verifies that Delete immediately removes from cache and persists async | |
| 177 | + | func TestDelete(t *testing.T) { | |
| 178 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 179 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |
| 180 | + | return map[string]int{"test": 42}, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | cache := NewGenericCache(mock) | |
| 184 | + | if err := cache.Load(context.Background()); err != nil { | |
| 185 | + | t.Fatalf("Load() failed: %v", err) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Verify entry exists | |
| 189 | + | if _, ok := cache.Get("test"); !ok { | |
| 190 | + | t.Fatal("Expected test to exist before Delete") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // Delete should return immediately | |
| 194 | + | err := cache.Delete("test") | |
| 195 | + | if err != nil { | |
| 196 | + | t.Fatalf("Delete() failed: %v", err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Value should be removed from cache immediately | |
| 200 | + | if val, ok := cache.Get("test"); ok { | |
| 201 | + | t.Errorf("Expected test to be deleted immediately, got %v", val) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Wait for async delete to complete | |
| 205 | + | if err := cache.Flush(); err != nil { | |
| 206 | + | t.Fatalf("Flush() failed: %v", err) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // Verify Delete was called | |
| 210 | + | if count := mock.GetDeleteCallCount(); count != 1 { | |
| 211 | + | t.Errorf("Expected Delete to be called once, got %d", count) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // Verify correct parameters | |
| 215 | + | lastCall := mock.GetLastDeleteCall() | |
| 216 | + | if lastCall == nil { | |
| 217 | + | t.Fatal("Expected Delete to be called") | |
| 218 | + | } | |
| 219 | + | if lastCall.Key != "test" { | |
| 220 | + | t.Errorf("Expected Delete(test), got Delete(%v)", lastCall.Key) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // TestList verifies that List returns all cached values | |
| 225 | + | func TestList(t *testing.T) { | |
| 226 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 227 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |
| 228 | + | return map[string]int{ | |
| 229 | + | "one": 1, | |
| 230 | + | "two": 2, | |
| 231 | + | "three": 3, | |
| 232 | + | }, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | cache := NewGenericCache(mock) | |
| 236 | + | if err := cache.Load(context.Background()); err != nil { | |
| 237 | + | t.Fatalf("Load() failed: %v", err) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | values := cache.List() | |
| 241 | + | if len(values) != 3 { | |
| 242 | + | t.Errorf("Expected 3 values, got %d", len(values)) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // Verify all values are present (order not guaranteed) | |
| 246 | + | found := make(map[int]bool) | |
| 247 | + | for _, v := range values { | |
| 248 | + | found[v] = true | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for _, expected := range []int{1, 2, 3} { | |
| 252 | + | if !found[expected] { | |
| 253 | + | t.Errorf("Expected value %d in List(), not found", expected) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // TestFlush verifies that Flush waits for all pending operations | |
| 259 | + | func TestFlush(t *testing.T) { | |
| 260 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 261 | + | persistDelay := make(chan struct{}) | |
| 262 | + | ||
| 263 | + | mock.PersistFunc = func(ctx context.Context, key string, value int) error { | |
| 264 | + | <-persistDelay // Wait for signal | |
| 265 | + | return nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | cache := NewGenericCache(mock) | |
| 269 | + | if err := cache.Load(context.Background()); err != nil { | |
| 270 | + | t.Fatalf("Load() failed: %v", err) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Start multiple async operations | |
| 274 | + | for i := 0; i < 5; i++ { | |
| 275 | + | if err := cache.Set("test", i); err != nil { | |
| 276 | + | t.Fatalf("Set() failed: %v", err) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // Verify operations haven't completed yet | |
| 281 | + | if count := mock.GetPersistCallCount(); count != 0 { | |
| 282 | + | t.Logf("Note: %d persists completed before signal", count) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Allow all operations to proceed | |
| 286 | + | close(persistDelay) | |
| 287 | + | ||
| 288 | + | // Flush should wait for all to complete | |
| 289 | + | if err := cache.Flush(); err != nil { | |
| 290 | + | t.Fatalf("Flush() failed: %v", err) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // All persists should have completed | |
| 294 | + | if count := mock.GetPersistCallCount(); count != 5 { | |
| 295 | + | t.Errorf("Expected 5 persists after Flush, got %d", count) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // TestConcurrentAccess verifies thread-safety of cache operations | |
| 300 | + | func TestConcurrentAccess(t *testing.T) { | |
| 301 | + | mock := NewMockDatabaseOperations[int, int]() | |
| 302 | + | ||
| 303 | + | cache := NewGenericCache(mock) | |
| 304 | + | if err := cache.Load(context.Background()); err != nil { | |
| 305 | + | t.Fatalf("Load() failed: %v", err) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | var wg sync.WaitGroup | |
| 309 | + | numGoroutines := 10 | |
| 310 | + | numOperations := 100 | |
| 311 | + | ||
| 312 | + | // Concurrent writes | |
| 313 | + | for i := 0; i < numGoroutines; i++ { | |
| 314 | + | wg.Add(1) | |
| 315 | + | go func(id int) { | |
| 316 | + | defer wg.Done() | |
| 317 | + | for j := 0; j < numOperations; j++ { | |
| 318 | + | key := id*numOperations + j | |
| 319 | + | if err := cache.Set(key, key*2); err != nil { | |
| 320 | + | t.Errorf("Set() failed: %v", err) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | }(i) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // Concurrent reads | |
| 327 | + | for i := 0; i < numGoroutines; i++ { | |
| 328 | + | wg.Add(1) | |
| 329 | + | go func() { | |
| 330 | + | defer wg.Done() | |
| 331 | + | for j := 0; j < numOperations; j++ { | |
| 332 | + | cache.Get(j) // Result doesn't matter, just testing for races | |
| 333 | + | } | |
| 334 | + | }() | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | wg.Wait() | |
| 338 | + | if err := cache.Flush(); err != nil { | |
| 339 | + | t.Fatalf("Flush() failed: %v", err) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // Verify expected number of entries | |
| 343 | + | values := cache.List() | |
| 344 | + | expectedEntries := numGoroutines * numOperations | |
| 345 | + | if len(values) != expectedEntries { | |
| 346 | + | t.Errorf("Expected %d entries after concurrent writes, got %d", expectedEntries, len(values)) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // Verify all Persists were called | |
| 350 | + | if count := mock.GetPersistCallCount(); count != expectedEntries { | |
| 351 | + | t.Errorf("Expected %d Persist calls, got %d", expectedEntries, count) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // TestPersistError verifies that persist errors are logged but don't break the cache | |
| 356 | + | func TestPersistError(t *testing.T) { | |
| 357 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 358 | + | expectedErr := errors.New("database write failed") | |
| 359 | + | ||
| 360 | + | mock.PersistFunc = func(ctx context.Context, key string, value int) error { | |
| 361 | + | return expectedErr | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | cache := NewGenericCache(mock) | |
| 365 | + | if err := cache.Load(context.Background()); err != nil { | |
| 366 | + | t.Fatalf("Load() failed: %v", err) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // Set should succeed even if persist fails | |
| 370 | + | err := cache.Set("test", 42) | |
| 371 | + | if err != nil { | |
| 372 | + | t.Errorf("Set() should not return error even if persist fails, got %v", err) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // Value should still be in cache | |
| 376 | + | val, ok := cache.Get("test") | |
| 377 | + | if !ok || val != 42 { | |
| 378 | + | t.Errorf("Expected test=42 in cache despite persist error, got %v, %v", val, ok) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // Flush should complete (errors are logged, not returned) | |
| 382 | + | if err := cache.Flush(); err != nil { | |
| 383 | + | t.Fatalf("Flush() failed: %v", err) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // Verify Persist was called despite error | |
| 387 | + | if count := mock.GetPersistCallCount(); count != 1 { | |
| 388 | + | t.Errorf("Expected Persist to be called once, got %d", count) | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // TestDeleteError verifies that delete errors are logged but don't break the cache | |
| 393 | + | func TestDeleteError(t *testing.T) { | |
| 394 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 395 | + | expectedErr := errors.New("database delete failed") | |
| 396 | + | ||
| 397 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |
| 398 | + | return map[string]int{"test": 42}, nil | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | mock.DeleteFunc = func(ctx context.Context, key string) error { | |
| 402 | + | return expectedErr | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | cache := NewGenericCache(mock) | |
| 406 | + | if err := cache.Load(context.Background()); err != nil { | |
| 407 | + | t.Fatalf("Load() failed: %v", err) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // Delete should succeed even if DB delete fails | |
| 411 | + | err := cache.Delete("test") | |
| 412 | + | if err != nil { | |
| 413 | + | t.Errorf("Delete() should not return error even if DB delete fails, got %v", err) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // Value should still be removed from cache | |
| 417 | + | if val, ok := cache.Get("test"); ok { | |
| 418 | + | t.Errorf("Expected test to be deleted from cache despite DB error, got %v", val) | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | if err := cache.Flush(); err != nil { | |
| 422 | + | t.Fatalf("Flush() failed: %v", err) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // Verify Delete was called despite error | |
| 426 | + | if count := mock.GetDeleteCallCount(); count != 1 { | |
| 427 | + | t.Errorf("Expected Delete to be called once, got %d", count) | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // TestMultipleSetsAndDeletes verifies correct handling of multiple operations | |
| 432 | + | func TestMultipleSetsAndDeletes(t *testing.T) { | |
| 433 | + | mock := NewMockDatabaseOperations[string, int]() | |
| 434 | + | ||
| 435 | + | cache := NewGenericCache(mock) | |
| 436 | + | if err := cache.Load(context.Background()); err != nil { | |
| 437 | + | t.Fatalf("Load() failed: %v", err) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // Perform multiple sets | |
| 441 | + | for i := 0; i < 10; i++ { | |
| 442 | + | if err := cache.Set("key", i); err != nil { | |
| 443 | + | t.Fatalf("Set() failed: %v", err) | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // Latest value should be in cache | |
| 448 | + | val, ok := cache.Get("key") | |
| 449 | + | if !ok || val != 9 { | |
| 450 | + | t.Errorf("Expected key=9, got %v, %v", val, ok) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // Flush and verify all persists were called | |
| 454 | + | if err := cache.Flush(); err != nil { | |
| 455 | + | t.Fatalf("Flush() failed: %v", err) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | if count := mock.GetPersistCallCount(); count != 10 { | |
| 459 | + | t.Errorf("Expected 10 Persist calls, got %d", count) | |
| 460 | + | } | |
| 461 | + | } | |
A internal/cache/generic/mock_db_ops.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package generic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // MockDatabaseOperations is a mock implementation of DatabaseOperations for testing. | |
| 9 | + | // It's generic and thread-safe, suitable for testing GenericCache. | |
| 10 | + | type MockDatabaseOperations[K comparable, V any] struct { | |
| 11 | + | mu sync.Mutex | |
| 12 | + | ||
| 13 | + | // Behavior configuration | |
| 14 | + | LoadAllFunc func(ctx context.Context) (map[K]V, error) | |
| 15 | + | PersistFunc func(ctx context.Context, key K, value V) error | |
| 16 | + | DeleteFunc func(ctx context.Context, key K) error | |
| 17 | + | ||
| 18 | + | // Call tracking | |
| 19 | + | LoadAllCalls []context.Context | |
| 20 | + | PersistCalls []struct { | |
| 21 | + | Ctx context.Context | |
| 22 | + | Key K | |
| 23 | + | Value V | |
| 24 | + | } | |
| 25 | + | DeleteCalls []struct { | |
| 26 | + | Ctx context.Context | |
| 27 | + | Key K | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // NewMockDatabaseOperations creates a new mock with default no-op implementations. | |
| 32 | + | func NewMockDatabaseOperations[K comparable, V any]() *MockDatabaseOperations[K, V] { | |
| 33 | + | return &MockDatabaseOperations[K, V]{ | |
| 34 | + | LoadAllFunc: func(ctx context.Context) (map[K]V, error) { | |
| 35 | + | return make(map[K]V), nil | |
| 36 | + | }, | |
| 37 | + | PersistFunc: func(ctx context.Context, key K, value V) error { | |
| 38 | + | return nil | |
| 39 | + | }, | |
| 40 | + | DeleteFunc: func(ctx context.Context, key K) error { | |
| 41 | + | return nil | |
| 42 | + | }, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // LoadAll implements DatabaseOperations.LoadAll | |
| 47 | + | func (m *MockDatabaseOperations[K, V]) LoadAll(ctx context.Context) (map[K]V, error) { | |
| 48 | + | m.mu.Lock() | |
| 49 | + | m.LoadAllCalls = append(m.LoadAllCalls, ctx) | |
| 50 | + | m.mu.Unlock() | |
| 51 | + | ||
| 52 | + | return m.LoadAllFunc(ctx) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Persist implements DatabaseOperations.Persist | |
| 56 | + | func (m *MockDatabaseOperations[K, V]) Persist(ctx context.Context, key K, value V) error { | |
| 57 | + | m.mu.Lock() | |
| 58 | + | m.PersistCalls = append(m.PersistCalls, struct { | |
| 59 | + | Ctx context.Context | |
| 60 | + | Key K | |
| 61 | + | Value V | |
| 62 | + | }{ctx, key, value}) | |
| 63 | + | m.mu.Unlock() | |
| 64 | + | ||
| 65 | + | return m.PersistFunc(ctx, key, value) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Delete implements DatabaseOperations.Delete | |
| 69 | + | func (m *MockDatabaseOperations[K, V]) Delete(ctx context.Context, key K) error { | |
| 70 | + | m.mu.Lock() | |
| 71 | + | m.DeleteCalls = append(m.DeleteCalls, struct { | |
| 72 | + | Ctx context.Context | |
| 73 | + | Key K | |
| 74 | + | }{ctx, key}) | |
| 75 | + | m.mu.Unlock() | |
| 76 | + | ||
| 77 | + | return m.DeleteFunc(ctx, key) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // GetLoadAllCallCount returns the number of times LoadAll was called | |
| 81 | + | func (m *MockDatabaseOperations[K, V]) GetLoadAllCallCount() int { | |
| 82 | + | m.mu.Lock() | |
| 83 | + | defer m.mu.Unlock() | |
| 84 | + | return len(m.LoadAllCalls) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // GetPersistCallCount returns the number of times Persist was called | |
| 88 | + | func (m *MockDatabaseOperations[K, V]) GetPersistCallCount() int { | |
| 89 | + | m.mu.Lock() | |
| 90 | + | defer m.mu.Unlock() | |
| 91 | + | return len(m.PersistCalls) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // GetDeleteCallCount returns the number of times Delete was called | |
| 95 | + | func (m *MockDatabaseOperations[K, V]) GetDeleteCallCount() int { | |
| 96 | + | m.mu.Lock() | |
| 97 | + | defer m.mu.Unlock() | |
| 98 | + | return len(m.DeleteCalls) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // GetLastPersistCall returns the last call to Persist, or nil if none | |
| 102 | + | func (m *MockDatabaseOperations[K, V]) GetLastPersistCall() *struct { | |
| 103 | + | Ctx context.Context | |
| 104 | + | Key K | |
| 105 | + | Value V | |
| 106 | + | } { | |
| 107 | + | m.mu.Lock() | |
| 108 | + | defer m.mu.Unlock() | |
| 109 | + | if len(m.PersistCalls) == 0 { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | last := m.PersistCalls[len(m.PersistCalls)-1] | |
| 113 | + | return &last | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // GetLastDeleteCall returns the last call to Delete, or nil if none | |
| 117 | + | func (m *MockDatabaseOperations[K, V]) GetLastDeleteCall() *struct { | |
| 118 | + | Ctx context.Context | |
| 119 | + | Key K | |
| 120 | + | } { | |
| 121 | + | m.mu.Lock() | |
| 122 | + | defer m.mu.Unlock() | |
| 123 | + | if len(m.DeleteCalls) == 0 { | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | last := m.DeleteCalls[len(m.DeleteCalls)-1] | |
| 127 | + | return &last | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Reset clears all call tracking | |
| 131 | + | func (m *MockDatabaseOperations[K, V]) Reset() { | |
| 132 | + | m.mu.Lock() | |
| 133 | + | defer m.mu.Unlock() | |
| 134 | + | m.LoadAllCalls = nil | |
| 135 | + | m.PersistCalls = nil | |
| 136 | + | m.DeleteCalls = nil | |
| 137 | + | } | |
M internal/cache/interfaces.go +0 -1
| @@ -11,5 +11,4 @@ import ( | |||
| 11 | 11 | // HistoryQuerier defines the interface for querying history data. | |
| 12 | 12 | type HistoryQuerier interface { | |
| 13 | 13 | GetHistory(ctx context.Context, limit int64) ([]db.History, error) | |
| 14 | - | GetShortcuts(ctx context.Context) ([]db.Shortcut, error) | |
| 15 | 14 | } | |
M internal/cache/mocks/mock_querier.go +8 -17
| @@ -1,5 +1,10 @@ | |||
| 1 | 1 | // Code generated by MockGen. DO NOT EDIT. | |
| 2 | 2 | // Source: interfaces.go | |
| 3 | + | // | |
| 4 | + | // Generated by this command: | |
| 5 | + | // | |
| 6 | + | // mockgen -source=interfaces.go -destination=mocks/mock_querier.go | |
| 7 | + | // | |
| 3 | 8 | ||
| 4 | 9 | // Package mock_cache is a generated GoMock package. | |
| 5 | 10 | package mock_cache | |
| @@ -9,13 +14,14 @@ import ( | |||
| 9 | 14 | reflect "reflect" | |
| 10 | 15 | ||
| 11 | 16 | db "github.com/bnema/dumber/internal/db" | |
| 12 | - | gomock "github.com/golang/mock/gomock" | |
| 17 | + | gomock "go.uber.org/mock/gomock" | |
| 13 | 18 | ) | |
| 14 | 19 | ||
| 15 | 20 | // MockHistoryQuerier is a mock of HistoryQuerier interface. | |
| 16 | 21 | type MockHistoryQuerier struct { | |
| 17 | 22 | ctrl *gomock.Controller | |
| 18 | 23 | recorder *MockHistoryQuerierMockRecorder | |
| 24 | + | isgomock struct{} | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 21 | 27 | // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier. | |
| @@ -45,22 +51,7 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db. | |||
| 45 | 51 | } | |
| 46 | 52 | ||
| 47 | 53 | // GetHistory indicates an expected call of GetHistory. | |
| 48 | - | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call { | |
| 54 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call { | |
| 49 | 55 | mr.mock.ctrl.T.Helper() | |
| 50 | 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit) | |
| 51 | 57 | } | |
| 52 | - | ||
| 53 | - | // GetShortcuts mocks base method. | |
| 54 | - | func (m *MockHistoryQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) { | |
| 55 | - | m.ctrl.T.Helper() | |
| 56 | - | ret := m.ctrl.Call(m, "GetShortcuts", ctx) | |
| 57 | - | ret0, _ := ret[0].([]db.Shortcut) | |
| 58 | - | ret1, _ := ret[1].(error) | |
| 59 | - | return ret0, ret1 | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // GetShortcuts indicates an expected call of GetShortcuts. | |
| 63 | - | func (mr *MockHistoryQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call { | |
| 64 | - | mr.mock.ctrl.T.Helper() | |
| 65 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockHistoryQuerier)(nil).GetShortcuts), ctx) | |
| 66 | - | } | |
A internal/cache/zoom_cache.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package cache | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/cache/generic" | |
| 7 | + | "github.com/bnema/dumber/internal/db" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ZoomDBOperations implements DatabaseOperations for zoom level cache. | |
| 11 | + | // Handles loading, persisting, and deleting zoom levels from the database. | |
| 12 | + | type ZoomDBOperations struct { | |
| 13 | + | queries db.DatabaseQuerier | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewZoomDBOperations creates a new ZoomDBOperations instance. | |
| 17 | + | func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations { | |
| 18 | + | return &ZoomDBOperations{ | |
| 19 | + | queries: queries, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // LoadAll loads all zoom levels from the database. | |
| 24 | + | // Returns a map of domain -> zoom factor. | |
| 25 | + | func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) { | |
| 26 | + | levels, err := z.queries.ListZoomLevels(ctx) | |
| 27 | + | if err != nil { | |
| 28 | + | return nil, err | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | result := make(map[string]float64, len(levels)) | |
| 32 | + | for _, level := range levels { | |
| 33 | + | result[level.Domain] = level.ZoomFactor | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | return result, nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Persist saves a zoom level to the database. | |
| 40 | + | // Uses UPSERT (INSERT OR UPDATE) to handle both new and existing entries. | |
| 41 | + | func (z *ZoomDBOperations) Persist(ctx context.Context, domain string, zoomFactor float64) error { | |
| 42 | + | return z.queries.SetZoomLevel(ctx, domain, zoomFactor) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Delete removes a zoom level from the database. | |
| 46 | + | func (z *ZoomDBOperations) Delete(ctx context.Context, domain string) error { | |
| 47 | + | return z.queries.DeleteZoomLevel(ctx, domain) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ZoomCache is a specialized cache for zoom levels. | |
| 51 | + | // It wraps GenericCache with domain-specific helper methods. | |
| 52 | + | type ZoomCache struct { | |
| 53 | + | *generic.GenericCache[string, float64] | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // NewZoomCache creates a new zoom level cache. | |
| 57 | + | func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache { | |
| 58 | + | dbOps := NewZoomDBOperations(queries) | |
| 59 | + | return &ZoomCache{ | |
| 60 | + | GenericCache: generic.NewGenericCache(dbOps), | |
| 61 | + | } | |
| 62 | + | } | |
M internal/cli/dmenu.go +7 -10
| @@ -172,11 +172,8 @@ func generateOptionsFallback(cli *CLI) error { | |||
| 172 | 172 | return fmt.Errorf("failed to get history: %w", err) | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | - | // Get shortcuts | |
| 176 | - | shortcuts, err := cli.Queries.GetShortcuts(ctx) | |
| 177 | - | if err != nil { | |
| 178 | - | return fmt.Errorf("failed to get shortcuts: %w", err) | |
| 179 | - | } | |
| 175 | + | // Get shortcuts from configuration (not database) | |
| 176 | + | shortcuts := cli.Config.SearchShortcuts | |
| 180 | 177 | ||
| 181 | 178 | // Generate options | |
| 182 | 179 | options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1) | |
| @@ -190,15 +187,15 @@ func generateOptionsFallback(cli *CLI) error { | |||
| 190 | 187 | }) | |
| 191 | 188 | ||
| 192 | 189 | // Add shortcuts with examples | |
| 193 | - | for _, shortcut := range shortcuts { | |
| 194 | - | desc := shortcut.Description.String | |
| 195 | - | if !shortcut.Description.Valid { | |
| 190 | + | for shortcutKey, shortcut := range shortcuts { | |
| 191 | + | desc := shortcut.Description | |
| 192 | + | if desc == "" { | |
| 196 | 193 | desc = "Custom shortcut" | |
| 197 | 194 | } | |
| 198 | 195 | ||
| 199 | 196 | options = append(options, DmenuOption{ | |
| 200 | - | Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc), | |
| 201 | - | Value: shortcut.Shortcut + ":", | |
| 197 | + | Display: fmt.Sprintf("🔍 %s: (%s)", shortcutKey, desc), | |
| 198 | + | Value: shortcutKey + ":", | |
| 202 | 199 | Type: "shortcut", | |
| 203 | 200 | Description: desc, | |
| 204 | 201 | }) | |
M internal/cli/logs.go +10 -2
| @@ -163,7 +163,11 @@ func tailFile(filePath string, lines int) error { | |||
| 163 | 163 | } | |
| 164 | 164 | return fmt.Errorf("failed to open log file: %w", err) | |
| 165 | 165 | } | |
| 166 | - | defer file.Close() | |
| 166 | + | defer func() { | |
| 167 | + | if err := file.Close(); err != nil { | |
| 168 | + | fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err) | |
| 169 | + | } | |
| 170 | + | }() | |
| 167 | 171 | ||
| 168 | 172 | // Read all lines | |
| 169 | 173 | var allLines []string | |
| @@ -335,7 +339,11 @@ func tailConsoleMessages(filePath string, lines int, level string) error { | |||
| 335 | 339 | } | |
| 336 | 340 | return fmt.Errorf("failed to open log file: %w", err) | |
| 337 | 341 | } | |
| 338 | - | defer file.Close() | |
| 342 | + | defer func() { | |
| 343 | + | if err := file.Close(); err != nil { | |
| 344 | + | fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err) | |
| 345 | + | } | |
| 346 | + | }() | |
| 339 | 347 | ||
| 340 | 348 | // Read all lines and filter for console messages | |
| 341 | 349 | var consoleLines []string | |
M internal/cli/purge.go +17 -19
| @@ -45,10 +45,13 @@ func NewPurgeCmd() *cobra.Command { | |||
| 45 | 45 | Long: `Purge various dumber data and cache files. By default, purges everything. | |
| 46 | 46 | ||
| 47 | 47 | Available purge targets: | |
| 48 | - | --database, -d Purge the SQLite database (history, shortcuts, zoom levels) | |
| 49 | - | --history-cache, -H Purge dmenu fuzzy search cache for history | |
| 48 | + | --database, -d Purge the SQLite database (history, zoom levels, cookies, | |
| 49 | + | certificate validations, and fuzzy cache metadata) | |
| 50 | + | --history-cache, -H Purge dmenu fuzzy search cache for history (binary file, will be | |
| 51 | + | database-backed in the future) | |
| 50 | 52 | --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.) | |
| 51 | - | --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage) | |
| 53 | + | --browser-data, -b Purge WebKit browser data (localStorage, sessionStorage, IndexedDB) | |
| 54 | + | Note: Cookies are now stored in the main database and deleted via --database | |
| 52 | 55 | --filter-cache, -F Purge compiled ad blocking filters cache | |
| 53 | 56 | --state, -s Purge all state data (includes database and caches) | |
| 54 | 57 | --config Purge configuration files | |
| @@ -56,34 +59,29 @@ Available purge targets: | |||
| 56 | 59 | ||
| 57 | 60 | Use --force to skip the confirmation prompt. | |
| 58 | 61 | ||
| 62 | + | Database Consolidation: | |
| 63 | + | Since version with database consolidation, cookies are stored in dumber.sqlite. | |
| 64 | + | Use --database to delete cookies along with history and other browser data. | |
| 65 | + | The --browser-data flag now only affects localStorage and other WebKit-managed data. | |
| 66 | + | ||
| 59 | 67 | Examples: | |
| 60 | 68 | dumber purge # Purge everything (with confirmation) | |
| 61 | 69 | dumber purge --force # Purge everything (no confirmation) | |
| 62 | 70 | dumber purge -d -H -c -F # Purge database and all caches | |
| 63 | - | dumber purge --filter-cache -f # Force purge filter cache only`, | |
| 71 | + | dumber purge --filter-cache -f # Force purge filter cache only | |
| 72 | + | dumber purge -d # Purge database (includes cookies)`, | |
| 64 | 73 | RunE: func(_ *cobra.Command, _ []string) error { | |
| 65 | - | // Initialize CLI to get config paths | |
| 66 | - | cli, err := NewCLI() | |
| 67 | - | if err != nil { | |
| 68 | - | return fmt.Errorf("failed to initialize CLI: %w", err) | |
| 69 | - | } | |
| 70 | - | defer func() { | |
| 71 | - | if closeErr := cli.Close(); closeErr != nil { | |
| 72 | - | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |
| 73 | - | } | |
| 74 | - | }() | |
| 75 | - | ||
| 76 | 74 | return executePurge(flags) | |
| 77 | 75 | }, | |
| 78 | 76 | } | |
| 79 | 77 | ||
| 80 | 78 | // Add flags | |
| 81 | - | cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database") | |
| 82 | - | cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache") | |
| 79 | + | cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database (includes cookies)") | |
| 80 | + | cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache (binary file)") | |
| 83 | 81 | cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache") | |
| 84 | - | cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)") | |
| 82 | + | cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (localStorage, sessionStorage)") | |
| 85 | 83 | cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache") | |
| 86 | - | cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data") | |
| 84 | + | cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data (database and caches)") | |
| 87 | 85 | cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files") | |
| 88 | 86 | cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything") | |
| 89 | 87 | cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt") | |
M internal/cli/root.go +3 -45
| @@ -65,14 +65,6 @@ func NewCLI() (*CLI, error) { | |||
| 65 | 65 | return nil, fmt.Errorf("failed to connect to database: %w", err) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | - | // Initialize database schema with configuration-based shortcuts | |
| 69 | - | if err := initializeDatabase(database, cfg); err != nil { | |
| 70 | - | if err := database.Close(); err != nil { | |
| 71 | - | log.Printf("Warning: failed to close database: %v", err) | |
| 72 | - | } | |
| 73 | - | return nil, fmt.Errorf("failed to initialize database: %w", err) | |
| 74 | - | } | |
| 75 | - | ||
| 76 | 68 | queries := db.New(database) | |
| 77 | 69 | parserService := services.NewParserService(cfg, queries) | |
| 78 | 70 | ||
| @@ -95,28 +87,11 @@ func (c *CLI) Close() error { | |||
| 95 | 87 | // NewRootCmd creates the root command for dumber | |
| 96 | 88 | func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |
| 97 | 89 | rootCmd := &cobra.Command{ | |
| 98 | - | Use: "dumber [url]", | |
| 90 | + | Use: "dumber", | |
| 99 | 91 | Short: "A dumb browser for Wayland window managers", | |
| 100 | 92 | Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`, | |
| 101 | - | Args: cobra.MaximumNArgs(1), | |
| 102 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 103 | - | // Initialize CLI | |
| 104 | - | cli, err := NewCLI() | |
| 105 | - | if err != nil { | |
| 106 | - | return fmt.Errorf("failed to initialize CLI: %w", err) | |
| 107 | - | } | |
| 108 | - | defer func() { | |
| 109 | - | if closeErr := cli.Close(); closeErr != nil { | |
| 110 | - | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |
| 111 | - | } | |
| 112 | - | }() | |
| 113 | - | ||
| 114 | - | // If URL provided, browse it directly | |
| 115 | - | if len(args) > 0 { | |
| 116 | - | return browse(cli, args[0]) | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // Otherwise show help | |
| 93 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 94 | + | // Show help when no subcommand is provided | |
| 120 | 95 | return cmd.Help() | |
| 121 | 96 | }, | |
| 122 | 97 | } | |
| @@ -183,20 +158,3 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 183 | 158 | ||
| 184 | 159 | return rootCmd | |
| 185 | 160 | } | |
| 186 | - | ||
| 187 | - | // initializeDatabase ensures configured shortcuts are inserted | |
| 188 | - | // Migrations are run automatically in db.InitDB() | |
| 189 | - | func initializeDatabase(database *sql.DB, cfg *config.Config) error { | |
| 190 | - | // Insert configured shortcuts (these are additive, won't override existing) | |
| 191 | - | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |
| 192 | - | _, err := database.Exec( | |
| 193 | - | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |
| 194 | - | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |
| 195 | - | ) | |
| 196 | - | if err != nil { | |
| 197 | - | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |
| 198 | - | } | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | return nil | |
| 202 | - | } | |
M internal/config/config.go +14 -94
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "sync" | |
| 11 | - | "time" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 13 | "github.com/bnema/dumber/pkg/gpu" | |
| @@ -32,9 +31,7 @@ type Config struct { | |||
| 32 | 31 | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | |
| 33 | 32 | VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"` | |
| 34 | 33 | CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"` | |
| 35 | - | WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"` | |
| 36 | 34 | Debug DebugConfig `mapstructure:"debug" yaml:"debug"` | |
| 37 | - | APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"` | |
| 38 | 35 | // RenderingMode controls GPU/CPU rendering selection for WebKit | |
| 39 | 36 | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | |
| 40 | 37 | // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom. | |
| @@ -58,10 +55,7 @@ const ( | |||
| 58 | 55 | ||
| 59 | 56 | // DatabaseConfig holds database-related configuration. | |
| 60 | 57 | type DatabaseConfig struct { | |
| 61 | - | Path string `mapstructure:"path" yaml:"path"` | |
| 62 | - | MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"` | |
| 63 | - | MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"` | |
| 64 | - | QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"` | |
| 58 | + | Path string `mapstructure:"path" yaml:"path"` | |
| 65 | 59 | } | |
| 66 | 60 | ||
| 67 | 61 | // HistoryConfig holds history-related configuration. | |
| @@ -178,35 +172,6 @@ type CodecConfig struct { | |||
| 178 | 172 | DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"` | |
| 179 | 173 | } | |
| 180 | 174 | ||
| 181 | - | // WebkitMemoryConfig holds WebKit memory optimization settings | |
| 182 | - | type WebkitMemoryConfig struct { | |
| 183 | - | // Cache model (document_viewer | web_browser | primary_web_browser) | |
| 184 | - | CacheModel string `mapstructure:"cache_model" yaml:"cache_model"` | |
| 185 | - | ||
| 186 | - | // Enable page cache for back/forward navigation | |
| 187 | - | EnablePageCache bool `mapstructure:"enable_page_cache" yaml:"enable_page_cache"` | |
| 188 | - | ||
| 189 | - | // Memory limit in MB (0 = system default) | |
| 190 | - | MemoryLimitMB int `mapstructure:"memory_limit_mb" yaml:"memory_limit_mb"` | |
| 191 | - | ||
| 192 | - | // Memory pressure thresholds (0.0-1.0) | |
| 193 | - | ConservativeThreshold float64 `mapstructure:"conservative_threshold" yaml:"conservative_threshold"` | |
| 194 | - | StrictThreshold float64 `mapstructure:"strict_threshold" yaml:"strict_threshold"` | |
| 195 | - | KillThreshold float64 `mapstructure:"kill_threshold" yaml:"kill_threshold"` | |
| 196 | - | ||
| 197 | - | // Memory monitoring interval in seconds | |
| 198 | - | PollIntervalSeconds float64 `mapstructure:"poll_interval_seconds" yaml:"poll_interval_seconds"` | |
| 199 | - | ||
| 200 | - | // Garbage collection interval in seconds (0 = disabled) | |
| 201 | - | EnableGCInterval int `mapstructure:"enable_gc_interval" yaml:"enable_gc_interval"` | |
| 202 | - | ||
| 203 | - | // Process recycling threshold (number of page loads) | |
| 204 | - | ProcessRecycleThreshold int `mapstructure:"process_recycle_threshold" yaml:"process_recycle_threshold"` | |
| 205 | - | ||
| 206 | - | // Enable memory monitoring logs | |
| 207 | - | EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"` | |
| 208 | - | } | |
| 209 | - | ||
| 210 | 175 | // DebugConfig holds debug and troubleshooting options | |
| 211 | 176 | type DebugConfig struct { | |
| 212 | 177 | // Enable WebKit internal debug logging | |
| @@ -247,14 +212,6 @@ type DebugConfig struct { | |||
| 247 | 212 | EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"` | |
| 248 | 213 | } | |
| 249 | 214 | ||
| 250 | - | // APISecurityConfig holds optional API key protection for dumb://api endpoints | |
| 251 | - | type APISecurityConfig struct { | |
| 252 | - | // If non-empty, token that requests must present via `token` query param | |
| 253 | - | Token string `mapstructure:"token" yaml:"token"` | |
| 254 | - | // If true, require token for all API endpoints (except a minimal allowlist) | |
| 255 | - | RequireToken bool `mapstructure:"require_token" yaml:"require_token"` | |
| 256 | - | } | |
| 257 | - | ||
| 258 | 215 | // WorkspaceConfig captures layout, pane, and tab behaviour preferences. | |
| 259 | 216 | type WorkspaceConfig struct { | |
| 260 | 217 | // EnableZellijControls toggles Zellij-inspired keybindings. | |
| @@ -371,12 +328,6 @@ func NewManager() (*Manager, error) { | |||
| 371 | 328 | // Bind specific environment variables | |
| 372 | 329 | bindings := map[string]string{ | |
| 373 | 330 | "database.path": "DATABASE_PATH", | |
| 374 | - | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | |
| 375 | - | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | |
| 376 | - | "database.query_timeout": "DATABASE_QUERY_TIMEOUT", | |
| 377 | - | "browser.command": "BROWSER_COMMAND", | |
| 378 | - | "browser.timeout": "BROWSER_TIMEOUT", | |
| 379 | - | "browser.detach_process": "BROWSER_DETACH_PROCESS", | |
| 380 | 331 | "history.max_entries": "HISTORY_MAX_ENTRIES", | |
| 381 | 332 | "history.retention_period": "HISTORY_RETENTION_PERIOD", | |
| 382 | 333 | "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL", | |
| @@ -395,9 +346,6 @@ func NewManager() (*Manager, error) { | |||
| 395 | 346 | "logging.max_backups": "LOGGING_MAX_BACKUPS", | |
| 396 | 347 | "logging.max_age": "LOGGING_MAX_AGE", | |
| 397 | 348 | "logging.compress": "LOGGING_COMPRESS", | |
| 398 | - | // API security | |
| 399 | - | "api_security.token": "API_TOKEN", | |
| 400 | - | "api_security.require_token": "API_REQUIRE_TOKEN", | |
| 401 | 349 | } | |
| 402 | 350 | ||
| 403 | 351 | for key, env := range bindings { | |
| @@ -453,26 +401,6 @@ func NewManager() (*Manager, error) { | |||
| 453 | 401 | } | |
| 454 | 402 | } | |
| 455 | 403 | ||
| 456 | - | // WebKit memory environment variable bindings | |
| 457 | - | memoryEnvBindings := map[string]string{ | |
| 458 | - | "webkit_memory.cache_model": "DUMBER_CACHE_MODEL", | |
| 459 | - | "webkit_memory.enable_page_cache": "DUMBER_ENABLE_PAGE_CACHE", | |
| 460 | - | "webkit_memory.memory_limit_mb": "DUMBER_MEMORY_LIMIT_MB", | |
| 461 | - | "webkit_memory.conservative_threshold": "DUMBER_MEMORY_CONSERVATIVE", | |
| 462 | - | "webkit_memory.strict_threshold": "DUMBER_MEMORY_STRICT", | |
| 463 | - | "webkit_memory.kill_threshold": "DUMBER_MEMORY_KILL", | |
| 464 | - | "webkit_memory.poll_interval_seconds": "DUMBER_MEMORY_POLL_INTERVAL", | |
| 465 | - | "webkit_memory.enable_gc_interval": "DUMBER_GC_INTERVAL", | |
| 466 | - | "webkit_memory.process_recycle_threshold": "DUMBER_RECYCLE_THRESHOLD", | |
| 467 | - | "webkit_memory.enable_memory_monitoring": "DUMBER_ENABLE_MEMORY_MONITORING", | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | for key, env := range memoryEnvBindings { | |
| 471 | - | if err := v.BindEnv(key, env); err != nil { | |
| 472 | - | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |
| 473 | - | } | |
| 474 | - | } | |
| 475 | - | ||
| 476 | 404 | return &Manager{ | |
| 477 | 405 | viper: v, | |
| 478 | 406 | callbacks: make([]func(*Config), 0), | |
| @@ -659,10 +587,7 @@ func (m *Manager) reload() error { | |||
| 659 | 587 | func (m *Manager) setDefaults() { | |
| 660 | 588 | defaults := DefaultConfig() | |
| 661 | 589 | ||
| 662 | - | // Database defaults | |
| 663 | - | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | |
| 664 | - | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | |
| 665 | - | m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout) | |
| 590 | + | // Note: Database.Path is set dynamically in Load(), no defaults needed | |
| 666 | 591 | ||
| 667 | 592 | // Note: Browser config removed - we build our own browser | |
| 668 | 593 | ||
| @@ -743,18 +668,6 @@ func (m *Manager) setDefaults() { | |||
| 743 | 668 | m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution) | |
| 744 | 669 | m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl) | |
| 745 | 670 | ||
| 746 | - | // WebKit memory defaults | |
| 747 | - | m.viper.SetDefault("webkit_memory.cache_model", defaults.WebkitMemory.CacheModel) | |
| 748 | - | m.viper.SetDefault("webkit_memory.enable_page_cache", defaults.WebkitMemory.EnablePageCache) | |
| 749 | - | m.viper.SetDefault("webkit_memory.memory_limit_mb", defaults.WebkitMemory.MemoryLimitMB) | |
| 750 | - | m.viper.SetDefault("webkit_memory.conservative_threshold", defaults.WebkitMemory.ConservativeThreshold) | |
| 751 | - | m.viper.SetDefault("webkit_memory.strict_threshold", defaults.WebkitMemory.StrictThreshold) | |
| 752 | - | m.viper.SetDefault("webkit_memory.kill_threshold", defaults.WebkitMemory.KillThreshold) | |
| 753 | - | m.viper.SetDefault("webkit_memory.poll_interval_seconds", defaults.WebkitMemory.PollIntervalSeconds) | |
| 754 | - | m.viper.SetDefault("webkit_memory.enable_gc_interval", defaults.WebkitMemory.EnableGCInterval) | |
| 755 | - | m.viper.SetDefault("webkit_memory.process_recycle_threshold", defaults.WebkitMemory.ProcessRecycleThreshold) | |
| 756 | - | m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring) | |
| 757 | - | ||
| 758 | 671 | // Rendering defaults | |
| 759 | 672 | m.viper.SetDefault("rendering_mode", string(RenderingModeGPU)) | |
| 760 | 673 | m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom) | |
| @@ -802,6 +715,13 @@ func (m *Manager) createDefaultConfig() error { | |||
| 802 | 715 | } | |
| 803 | 716 | ||
| 804 | 717 | fmt.Printf("Created default configuration file: %s\n", configFile) | |
| 718 | + | ||
| 719 | + | // Generate JSON schema file | |
| 720 | + | if err := GenerateSchemaFile(); err != nil { | |
| 721 | + | // Log error but don't fail config creation | |
| 722 | + | fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err) | |
| 723 | + | } | |
| 724 | + | ||
| 805 | 725 | return nil | |
| 806 | 726 | } | |
| 807 | 727 | ||
| @@ -907,17 +827,17 @@ func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config { | |||
| 907 | 827 | // validateColorScheme validates and normalizes the ColorScheme setting | |
| 908 | 828 | func (m *Manager) validateColorScheme(config *Config) { | |
| 909 | 829 | switch config.Appearance.ColorScheme { | |
| 910 | - | case "prefer-dark", "prefer-light", "default", "": | |
| 830 | + | case "prefer-dark", "prefer-light", ThemeDefault, "": | |
| 911 | 831 | // Valid values - no changes needed | |
| 912 | 832 | // Empty string is treated the same as "default" | |
| 913 | 833 | if config.Appearance.ColorScheme == "" { | |
| 914 | - | config.Appearance.ColorScheme = "default" | |
| 915 | - | logging.Info("Config: ColorScheme not set, defaulting to 'default' (follows system)") | |
| 834 | + | config.Appearance.ColorScheme = ThemeDefault | |
| 835 | + | logging.Info("Config: ColorScheme not set, defaulting to '" + ThemeDefault + "' (follows system)") | |
| 916 | 836 | } | |
| 917 | 837 | default: | |
| 918 | 838 | // Invalid value - warn and reset to default | |
| 919 | - | logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', 'default'. Resetting to 'default'", | |
| 839 | + | logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', '"+ThemeDefault+"'. Resetting to '"+ThemeDefault+"'", | |
| 920 | 840 | config.Appearance.ColorScheme)) | |
| 921 | - | config.Appearance.ColorScheme = "default" | |
| 841 | + | config.Appearance.ColorScheme = ThemeDefault | |
| 922 | 842 | } | |
| 923 | 843 | } | |
A internal/config/constants.go +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | // Color scheme constants | |
| 4 | + | const ( | |
| 5 | + | ThemeDefault = "default" | |
| 6 | + | ) | |
M internal/config/defaults.go +8 -34
| @@ -1,16 +1,8 @@ | |||
| 1 | 1 | // Package config provides default configuration values for dumber. | |
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | - | import ( | |
| 5 | - | "time" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | 4 | // Default configuration constants | |
| 9 | 5 | const ( | |
| 10 | - | // Database defaults | |
| 11 | - | defaultMaxIdleTimeMin = 5 // minutes | |
| 12 | - | defaultQueryTimeoutSec = 30 // seconds | |
| 13 | - | ||
| 14 | 6 | // History defaults | |
| 15 | 7 | defaultMaxHistoryEntries = 10000 // entries | |
| 16 | 8 | defaultRetentionDays = 365 // 1 year | |
| @@ -27,7 +19,7 @@ const ( | |||
| 27 | 19 | defaultFontSize = 16 // points | |
| 28 | 20 | ||
| 29 | 21 | // Workspace defaults | |
| 30 | - | defaultPaneActivationShortcut = "cmdorctrl+p" | |
| 22 | + | defaultPaneActivationShortcut = "ctrl+p" | |
| 31 | 23 | defaultPaneTimeoutMilliseconds = 3000 | |
| 32 | 24 | defaultPopupPlacement = "right" | |
| 33 | 25 | ||
| @@ -52,9 +44,7 @@ func getDefaultLogDir() string { | |||
| 52 | 44 | func DefaultConfig() *Config { | |
| 53 | 45 | return &Config{ | |
| 54 | 46 | Database: DatabaseConfig{ | |
| 55 | - | MaxConnections: 1, | |
| 56 | - | MaxIdleTime: time.Minute * defaultMaxIdleTimeMin, | |
| 57 | - | QueryTimeout: time.Second * defaultQueryTimeoutSec, | |
| 47 | + | // Path is set dynamically in config.Load() | |
| 58 | 48 | }, | |
| 59 | 49 | History: HistoryConfig{ | |
| 60 | 50 | MaxEntries: defaultMaxHistoryEntries, | |
| @@ -131,20 +121,8 @@ func DefaultConfig() *Config { | |||
| 131 | 121 | VideoBufferSizeMB: 16, // Larger buffer for AV1 streams | |
| 132 | 122 | QueueBufferTimeSec: 10, // More buffering time for smooth playback | |
| 133 | 123 | CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support | |
| 134 | - | AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res | |
| 135 | - | DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing) | |
| 136 | - | }, | |
| 137 | - | WebkitMemory: WebkitMemoryConfig{ | |
| 138 | - | CacheModel: "web_browser", // Aggressive caching for fast page loads | |
| 139 | - | EnablePageCache: true, // Instant back/forward navigation | |
| 140 | - | MemoryLimitMB: 0, // Use system default | |
| 141 | - | ConservativeThreshold: 0.4, // Start cleanup at 40% | |
| 142 | - | StrictThreshold: 0.6, // Strict cleanup at 60% | |
| 143 | - | KillThreshold: 0.8, // Kill processes at 80% | |
| 144 | - | PollIntervalSeconds: 45.0, // Check every 45 seconds | |
| 145 | - | EnableGCInterval: 120, // GC every 2 minutes | |
| 146 | - | ProcessRecycleThreshold: 50, // Recycle after 50 page loads | |
| 147 | - | EnableMemoryMonitoring: true, // Monitor for production tuning | |
| 124 | + | AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res | |
| 125 | + | DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing) | |
| 148 | 126 | }, | |
| 149 | 127 | Debug: DebugConfig{ | |
| 150 | 128 | EnableWebKitDebug: false, | |
| @@ -160,10 +138,6 @@ func DefaultConfig() *Config { | |||
| 160 | 138 | EnableFocusMetrics: false, | |
| 161 | 139 | EnablePaneCloseDebug: false, | |
| 162 | 140 | }, | |
| 163 | - | APISecurity: APISecurityConfig{ | |
| 164 | - | Token: "", | |
| 165 | - | RequireToken: false, | |
| 166 | - | }, | |
| 167 | 141 | RenderingMode: RenderingModeGPU, | |
| 168 | 142 | UseDomZoom: false, | |
| 169 | 143 | DefaultZoom: 1.2, // 120% default zoom for better readability | |
| @@ -187,10 +161,10 @@ func DefaultConfig() *Config { | |||
| 187 | 161 | }, | |
| 188 | 162 | }, | |
| 189 | 163 | Tabs: TabKeyConfig{ | |
| 190 | - | NewTab: "cmdorctrl+t", | |
| 191 | - | CloseTab: "cmdorctrl+w", | |
| 192 | - | NextTab: "cmdorctrl+tab", | |
| 193 | - | PreviousTab: "cmdorctrl+shift+tab", | |
| 164 | + | NewTab: "ctrl+t", | |
| 165 | + | CloseTab: "ctrl+w", | |
| 166 | + | NextTab: "ctrl+tab", | |
| 167 | + | PreviousTab: "ctrl+shift+tab", | |
| 194 | 168 | }, | |
| 195 | 169 | Popups: PopupBehaviorConfig{ | |
| 196 | 170 | Behavior: PopupBehaviorSplit, // Default: open popups in split panes | |
A internal/config/schema.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/invopop/jsonschema" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // GenerateSchemaFile generates a JSON schema file for the configuration. | |
| 13 | + | // This is called automatically when a default config is created. | |
| 14 | + | func GenerateSchemaFile() error { | |
| 15 | + | configDir, err := GetConfigDir() | |
| 16 | + | if err != nil { | |
| 17 | + | return fmt.Errorf("failed to get config directory: %w", err) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | schemaFile := filepath.Join(configDir, "config.schema.json") | |
| 21 | + | ||
| 22 | + | // Create reflector and generate schema | |
| 23 | + | r := new(jsonschema.Reflector) | |
| 24 | + | schema := r.Reflect(&Config{}) | |
| 25 | + | ||
| 26 | + | // Set schema metadata | |
| 27 | + | schema.ID = "https://github.com/bnema/dumber/config.schema.json" | |
| 28 | + | schema.Title = "Dumber Browser Configuration" | |
| 29 | + | schema.Description = "Configuration schema for Dumber, a minimalist browser for tiling window managers" | |
| 30 | + | ||
| 31 | + | // Marshal to pretty JSON | |
| 32 | + | data, err := json.MarshalIndent(schema, "", " ") | |
| 33 | + | if err != nil { | |
| 34 | + | return fmt.Errorf("failed to marshal schema: %w", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Write schema file | |
| 38 | + | if err := os.WriteFile(schemaFile, data, filePerm); err != nil { | |
| 39 | + | return fmt.Errorf("failed to write schema file: %w", err) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | fmt.Printf("Generated JSON schema: %s\n", schemaFile) | |
| 43 | + | return nil | |
| 44 | + | } | |
M internal/config/xdg.go +9 -4
| @@ -6,7 +6,10 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | const appName = "dumber" | |
| 9 | + | const ( | |
| 10 | + | appName = "dumber" | |
| 11 | + | databaseName = "dumber.sqlite" | |
| 12 | + | ) | |
| 10 | 13 | ||
| 11 | 14 | // XDGDirs holds the XDG Base Directory paths for the application. | |
| 12 | 15 | type XDGDirs struct { | |
| @@ -114,13 +117,15 @@ func GetConfigFile() (string, error) { | |||
| 114 | 117 | return filepath.Join(configDir, "config.json"), nil | |
| 115 | 118 | } | |
| 116 | 119 | ||
| 117 | - | // GetDatabaseFile returns the path to the database file in the state directory. | |
| 120 | + | // GetDatabaseFile returns the path to the database file in the data directory. | |
| 121 | + | // The database contains important user data (history, cookies, preferences) and | |
| 122 | + | // therefore belongs in XDG_DATA_HOME, not XDG_STATE_HOME. | |
| 118 | 123 | func GetDatabaseFile() (string, error) { | |
| 119 | - | stateDir, err := GetStateDir() | |
| 124 | + | dataDir, err := GetDataDir() | |
| 120 | 125 | if err != nil { | |
| 121 | 126 | return "", err | |
| 122 | 127 | } | |
| 123 | - | return filepath.Join(stateDir, "history.db"), nil | |
| 128 | + | return filepath.Join(dataDir, databaseName), nil | |
| 124 | 129 | } | |
| 125 | 130 | ||
| 126 | 131 | // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber. | |
M internal/db/certificate_validations.sql.go +37 -1
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec | |
| 14 | - | DELETE FROM certificate_validations | |
| 14 | + | DELETE FROM certificate_validations | |
| 15 | 15 | WHERE hostname = ? AND certificate_hash = ? | |
| 16 | 16 | ` | |
| 17 | 17 | ||
| @@ -73,6 +73,42 @@ func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostna | |||
| 73 | 73 | return i, err | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | const ListCertificateValidations = `-- name: ListCertificateValidations :many | |
| 77 | + | SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations | |
| 78 | + | WHERE (expires_at IS NULL OR expires_at > datetime('now')) | |
| 79 | + | ORDER BY hostname | |
| 80 | + | ` | |
| 81 | + | ||
| 82 | + | func (q *Queries) ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) { | |
| 83 | + | rows, err := q.db.QueryContext(ctx, ListCertificateValidations) | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, err | |
| 86 | + | } | |
| 87 | + | defer rows.Close() | |
| 88 | + | items := []CertificateValidation{} | |
| 89 | + | for rows.Next() { | |
| 90 | + | var i CertificateValidation | |
| 91 | + | if err := rows.Scan( | |
| 92 | + | &i.ID, | |
| 93 | + | &i.Hostname, | |
| 94 | + | &i.CertificateHash, | |
| 95 | + | &i.UserDecision, | |
| 96 | + | &i.CreatedAt, | |
| 97 | + | &i.ExpiresAt, | |
| 98 | + | ); err != nil { | |
| 99 | + | return nil, err | |
| 100 | + | } | |
| 101 | + | items = append(items, i) | |
| 102 | + | } | |
| 103 | + | if err := rows.Close(); err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | if err := rows.Err(); err != nil { | |
| 107 | + | return nil, err | |
| 108 | + | } | |
| 109 | + | return items, nil | |
| 110 | + | } | |
| 111 | + | ||
| 76 | 112 | const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec | |
| 77 | 113 | INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at) | |
| 78 | 114 | VALUES (?, ?, ?, ?) | |
M internal/db/history.sql.go +0 -35
| @@ -133,41 +133,6 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset | |||
| 133 | 133 | return items, nil | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | - | const GetShortcuts = `-- name: GetShortcuts :many | |
| 137 | - | SELECT id, shortcut, url_template, description, created_at | |
| 138 | - | FROM shortcuts | |
| 139 | - | ORDER BY shortcut | |
| 140 | - | ` | |
| 141 | - | ||
| 142 | - | func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) { | |
| 143 | - | rows, err := q.db.QueryContext(ctx, GetShortcuts) | |
| 144 | - | if err != nil { | |
| 145 | - | return nil, err | |
| 146 | - | } | |
| 147 | - | defer rows.Close() | |
| 148 | - | items := []Shortcut{} | |
| 149 | - | for rows.Next() { | |
| 150 | - | var i Shortcut | |
| 151 | - | if err := rows.Scan( | |
| 152 | - | &i.ID, | |
| 153 | - | &i.Shortcut, | |
| 154 | - | &i.UrlTemplate, | |
| 155 | - | &i.Description, | |
| 156 | - | &i.CreatedAt, | |
| 157 | - | ); err != nil { | |
| 158 | - | return nil, err | |
| 159 | - | } | |
| 160 | - | items = append(items, i) | |
| 161 | - | } | |
| 162 | - | if err := rows.Close(); err != nil { | |
| 163 | - | return nil, err | |
| 164 | - | } | |
| 165 | - | if err := rows.Err(); err != nil { | |
| 166 | - | return nil, err | |
| 167 | - | } | |
| 168 | - | return items, nil | |
| 169 | - | } | |
| 170 | - | ||
| 171 | 136 | const SearchHistory = `-- name: SearchHistory :many | |
| 172 | 137 | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | |
| 173 | 138 | FROM history | |
M internal/db/init.go +22 -47
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/dumber/internal/config" | |
| 11 | 10 | "github.com/bnema/dumber/internal/migrations" | |
| 12 | 11 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | |
| 13 | 12 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | |
| @@ -20,7 +19,7 @@ func InitDB(dbPath string) (*sql.DB, error) { | |||
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | 21 | // Ensure database directory exists | |
| 23 | - | if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { | |
| 22 | + | if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil { | |
| 24 | 23 | return nil, fmt.Errorf("failed to create database directory: %w", err) | |
| 25 | 24 | } | |
| 26 | 25 | ||
| @@ -38,59 +37,35 @@ func InitDB(dbPath string) (*sql.DB, error) { | |||
| 38 | 37 | return nil, fmt.Errorf("failed to connect to database: %w", err) | |
| 39 | 38 | } | |
| 40 | 39 | ||
| 41 | - | // Run embedded migrations - single source of truth for schema initialization | |
| 42 | - | if err := migrations.RunEmbeddedMigrations(database); err != nil { | |
| 43 | - | if err := database.Close(); err != nil { | |
| 44 | - | log.Printf("Warning: failed to close database: %v", err) | |
| 45 | - | } | |
| 46 | - | return nil, fmt.Errorf("failed to run database migrations: %w", err) | |
| 40 | + | // Configure SQLite for optimal performance | |
| 41 | + | pragmas := []string{ | |
| 42 | + | "PRAGMA journal_mode = WAL", // Write-Ahead Logging: 70k reads/sec, 3.6k writes/sec, concurrent access | |
| 43 | + | "PRAGMA synchronous = NORMAL", // Safe in WAL mode, only checkpoints wait for FSYNC | |
| 44 | + | "PRAGMA cache_size = -64000", // 64MB cache (default is only ~2MB) | |
| 45 | + | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM | |
| 46 | + | "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O (virtual memory, OS-managed) | |
| 47 | + | "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | - | // Verify all migrations are applied | |
| 50 | - | if err := migrations.VerifyAllMigrationsApplied(database); err != nil { | |
| 51 | - | if err := database.Close(); err != nil { | |
| 52 | - | log.Printf("Warning: failed to close database: %v", err) | |
| 50 | + | for _, pragma := range pragmas { | |
| 51 | + | if _, err := database.Exec(pragma); err != nil { | |
| 52 | + | if err := database.Close(); err != nil { | |
| 53 | + | log.Printf("Warning: failed to close database: %v", err) | |
| 54 | + | } | |
| 55 | + | return nil, fmt.Errorf("failed to set pragma %q: %w", pragma, err) | |
| 53 | 56 | } | |
| 54 | - | return nil, fmt.Errorf("migration verification failed: %w", err) | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 57 | - | return database, nil | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // InitDBWithConfig initializes the database with configuration-based shortcuts | |
| 61 | - | func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) { | |
| 62 | - | db, err := InitDB(dbPath) | |
| 63 | - | if err != nil { | |
| 64 | - | return nil, err | |
| 65 | - | } | |
| 59 | + | log.Printf("SQLite configured with WAL mode and performance optimizations") | |
| 66 | 60 | ||
| 67 | - | // Initialize shortcuts from configuration | |
| 68 | - | if err := initializeShortcuts(db, cfg); err != nil { | |
| 69 | - | if err := db.Close(); err != nil { | |
| 61 | + | // Run embedded migrations - single source of truth for schema initialization | |
| 62 | + | // RunEmbeddedMigrations is idempotent and checks if migrations are already applied | |
| 63 | + | if err := migrations.RunEmbeddedMigrations(database); err != nil { | |
| 64 | + | if err := database.Close(); err != nil { | |
| 70 | 65 | log.Printf("Warning: failed to close database: %v", err) | |
| 71 | 66 | } | |
| 72 | - | return nil, fmt.Errorf("failed to initialize shortcuts: %w", err) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | return db, nil | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // initializeShortcuts inserts configured shortcuts into the database | |
| 79 | - | func initializeShortcuts(db *sql.DB, cfg *config.Config) error { | |
| 80 | - | if cfg == nil || cfg.SearchShortcuts == nil { | |
| 81 | - | return nil // No shortcuts to initialize | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // Insert configured shortcuts | |
| 85 | - | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |
| 86 | - | _, err := db.Exec( | |
| 87 | - | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |
| 88 | - | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |
| 89 | - | ) | |
| 90 | - | if err != nil { | |
| 91 | - | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |
| 92 | - | } | |
| 67 | + | return nil, fmt.Errorf("failed to run database migrations: %w", err) | |
| 93 | 68 | } | |
| 94 | 69 | ||
| 95 | - | return nil | |
| 70 | + | return database, nil | |
| 96 | 71 | } | |
M internal/db/interfaces.go +10 -4
| @@ -12,6 +12,7 @@ type ZoomQuerier interface { | |||
| 12 | 12 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | |
| 13 | 13 | SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error | |
| 14 | 14 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 15 | + | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 17 | 18 | // HistoryQuerier defines the interface for history-related database operations | |
| @@ -25,16 +26,21 @@ type HistoryQuerier interface { | |||
| 25 | 26 | DeleteHistory(ctx context.Context, id int64) error | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | - | // ShortcutQuerier defines the interface for shortcut-related database operations | |
| 29 | - | type ShortcutQuerier interface { | |
| 30 | - | GetShortcuts(ctx context.Context) ([]Shortcut, error) | |
| 29 | + | // CertificateQuerier defines the interface for certificate validation-related database operations | |
| 30 | + | type CertificateQuerier interface { | |
| 31 | + | ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) | |
| 32 | + | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | |
| 33 | + | GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) | |
| 34 | + | StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error | |
| 35 | + | DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error | |
| 36 | + | DeleteExpiredCertificateValidations(ctx context.Context) error | |
| 31 | 37 | } | |
| 32 | 38 | ||
| 33 | 39 | // DatabaseQuerier combines all database operation interfaces | |
| 34 | 40 | type DatabaseQuerier interface { | |
| 35 | 41 | ZoomQuerier | |
| 36 | 42 | HistoryQuerier | |
| 37 | - | ShortcutQuerier | |
| 43 | + | CertificateQuerier | |
| 38 | 44 | } | |
| 39 | 45 | ||
| 40 | 46 | // Ensure that *Queries implements DatabaseQuerier interface | |
M internal/db/mocks/mock_db.go +337 -38
| @@ -1,5 +1,10 @@ | |||
| 1 | 1 | // Code generated by MockGen. DO NOT EDIT. | |
| 2 | 2 | // Source: interfaces.go | |
| 3 | + | // | |
| 4 | + | // Generated by this command: | |
| 5 | + | // | |
| 6 | + | // mockgen -source=interfaces.go -destination=mocks/mock_db.go | |
| 7 | + | // | |
| 3 | 8 | ||
| 4 | 9 | // Package mock_db is a generated GoMock package. | |
| 5 | 10 | package mock_db | |
| @@ -10,13 +15,14 @@ import ( | |||
| 10 | 15 | reflect "reflect" | |
| 11 | 16 | ||
| 12 | 17 | db "github.com/bnema/dumber/internal/db" | |
| 13 | - | gomock "github.com/golang/mock/gomock" | |
| 18 | + | gomock "go.uber.org/mock/gomock" | |
| 14 | 19 | ) | |
| 15 | 20 | ||
| 16 | 21 | // MockZoomQuerier is a mock of ZoomQuerier interface. | |
| 17 | 22 | type MockZoomQuerier struct { | |
| 18 | 23 | ctrl *gomock.Controller | |
| 19 | 24 | recorder *MockZoomQuerierMockRecorder | |
| 25 | + | isgomock struct{} | |
| 20 | 26 | } | |
| 21 | 27 | ||
| 22 | 28 | // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier. | |
| @@ -45,7 +51,7 @@ func (m *MockZoomQuerier) DeleteZoomLevel(ctx context.Context, domain string) er | |||
| 45 | 51 | } | |
| 46 | 52 | ||
| 47 | 53 | // DeleteZoomLevel indicates an expected call of DeleteZoomLevel. | |
| 48 | - | func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call { | |
| 54 | + | func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call { | |
| 49 | 55 | mr.mock.ctrl.T.Helper() | |
| 50 | 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain) | |
| 51 | 57 | } | |
| @@ -60,11 +66,26 @@ func (m *MockZoomQuerier) GetZoomLevel(ctx context.Context, domain string) (floa | |||
| 60 | 66 | } | |
| 61 | 67 | ||
| 62 | 68 | // GetZoomLevel indicates an expected call of GetZoomLevel. | |
| 63 | - | func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call { | |
| 69 | + | func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call { | |
| 64 | 70 | mr.mock.ctrl.T.Helper() | |
| 65 | 71 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain) | |
| 66 | 72 | } | |
| 67 | 73 | ||
| 74 | + | // ListZoomLevels mocks base method. | |
| 75 | + | func (m *MockZoomQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) { | |
| 76 | + | m.ctrl.T.Helper() | |
| 77 | + | ret := m.ctrl.Call(m, "ListZoomLevels", ctx) | |
| 78 | + | ret0, _ := ret[0].([]db.ZoomLevel) | |
| 79 | + | ret1, _ := ret[1].(error) | |
| 80 | + | return ret0, ret1 | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ListZoomLevels indicates an expected call of ListZoomLevels. | |
| 84 | + | func (mr *MockZoomQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call { | |
| 85 | + | mr.mock.ctrl.T.Helper() | |
| 86 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockZoomQuerier)(nil).ListZoomLevels), ctx) | |
| 87 | + | } | |
| 88 | + | ||
| 68 | 89 | // SetZoomLevel mocks base method. | |
| 69 | 90 | func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error { | |
| 70 | 91 | m.ctrl.T.Helper() | |
| @@ -74,7 +95,7 @@ func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomL | |||
| 74 | 95 | } | |
| 75 | 96 | ||
| 76 | 97 | // SetZoomLevel indicates an expected call of SetZoomLevel. | |
| 77 | - | func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call { | |
| 98 | + | func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call { | |
| 78 | 99 | mr.mock.ctrl.T.Helper() | |
| 79 | 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel) | |
| 80 | 101 | } | |
| @@ -83,6 +104,7 @@ func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel inter | |||
| 83 | 104 | type MockHistoryQuerier struct { | |
| 84 | 105 | ctrl *gomock.Controller | |
| 85 | 106 | recorder *MockHistoryQuerierMockRecorder | |
| 107 | + | isgomock struct{} | |
| 86 | 108 | } | |
| 87 | 109 | ||
| 88 | 110 | // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier. | |
| @@ -111,11 +133,25 @@ func (m *MockHistoryQuerier) AddOrUpdateHistory(ctx context.Context, url string, | |||
| 111 | 133 | } | |
| 112 | 134 | ||
| 113 | 135 | // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory. | |
| 114 | - | func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call { | |
| 136 | + | func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call { | |
| 115 | 137 | mr.mock.ctrl.T.Helper() | |
| 116 | 138 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title) | |
| 117 | 139 | } | |
| 118 | 140 | ||
| 141 | + | // DeleteHistory mocks base method. | |
| 142 | + | func (m *MockHistoryQuerier) DeleteHistory(ctx context.Context, id int64) error { | |
| 143 | + | m.ctrl.T.Helper() | |
| 144 | + | ret := m.ctrl.Call(m, "DeleteHistory", ctx, id) | |
| 145 | + | ret0, _ := ret[0].(error) | |
| 146 | + | return ret0 | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // DeleteHistory indicates an expected call of DeleteHistory. | |
| 150 | + | func (mr *MockHistoryQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call { | |
| 151 | + | mr.mock.ctrl.T.Helper() | |
| 152 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).DeleteHistory), ctx, id) | |
| 153 | + | } | |
| 154 | + | ||
| 119 | 155 | // GetHistory mocks base method. | |
| 120 | 156 | func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) { | |
| 121 | 157 | m.ctrl.T.Helper() | |
| @@ -126,11 +162,41 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db. | |||
| 126 | 162 | } | |
| 127 | 163 | ||
| 128 | 164 | // GetHistory indicates an expected call of GetHistory. | |
| 129 | - | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call { | |
| 165 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call { | |
| 130 | 166 | mr.mock.ctrl.T.Helper() | |
| 131 | 167 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit) | |
| 132 | 168 | } | |
| 133 | 169 | ||
| 170 | + | // GetHistoryEntry mocks base method. | |
| 171 | + | func (m *MockHistoryQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) { | |
| 172 | + | m.ctrl.T.Helper() | |
| 173 | + | ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url) | |
| 174 | + | ret0, _ := ret[0].(db.History) | |
| 175 | + | ret1, _ := ret[1].(error) | |
| 176 | + | return ret0, ret1 | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // GetHistoryEntry indicates an expected call of GetHistoryEntry. | |
| 180 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call { | |
| 181 | + | mr.mock.ctrl.T.Helper() | |
| 182 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryEntry), ctx, url) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // GetHistoryWithOffset mocks base method. | |
| 186 | + | func (m *MockHistoryQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) { | |
| 187 | + | m.ctrl.T.Helper() | |
| 188 | + | ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset) | |
| 189 | + | ret0, _ := ret[0].([]db.History) | |
| 190 | + | ret1, _ := ret[1].(error) | |
| 191 | + | return ret0, ret1 | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset. | |
| 195 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call { | |
| 196 | + | mr.mock.ctrl.T.Helper() | |
| 197 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset) | |
| 198 | + | } | |
| 199 | + | ||
| 134 | 200 | // SearchHistory mocks base method. | |
| 135 | 201 | func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) { | |
| 136 | 202 | m.ctrl.T.Helper() | |
| @@ -141,53 +207,141 @@ func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 | |||
| 141 | 207 | } | |
| 142 | 208 | ||
| 143 | 209 | // SearchHistory indicates an expected call of SearchHistory. | |
| 144 | - | func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call { | |
| 210 | + | func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call { | |
| 145 | 211 | mr.mock.ctrl.T.Helper() | |
| 146 | 212 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit) | |
| 147 | 213 | } | |
| 148 | 214 | ||
| 149 | - | // MockShortcutQuerier is a mock of ShortcutQuerier interface. | |
| 150 | - | type MockShortcutQuerier struct { | |
| 215 | + | // UpdateHistoryFavicon mocks base method. | |
| 216 | + | func (m *MockHistoryQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error { | |
| 217 | + | m.ctrl.T.Helper() | |
| 218 | + | ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url) | |
| 219 | + | ret0, _ := ret[0].(error) | |
| 220 | + | return ret0 | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon. | |
| 224 | + | func (mr *MockHistoryQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call { | |
| 225 | + | mr.mock.ctrl.T.Helper() | |
| 226 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockHistoryQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // MockCertificateQuerier is a mock of CertificateQuerier interface. | |
| 230 | + | type MockCertificateQuerier struct { | |
| 151 | 231 | ctrl *gomock.Controller | |
| 152 | - | recorder *MockShortcutQuerierMockRecorder | |
| 232 | + | recorder *MockCertificateQuerierMockRecorder | |
| 233 | + | isgomock struct{} | |
| 153 | 234 | } | |
| 154 | 235 | ||
| 155 | - | // MockShortcutQuerierMockRecorder is the mock recorder for MockShortcutQuerier. | |
| 156 | - | type MockShortcutQuerierMockRecorder struct { | |
| 157 | - | mock *MockShortcutQuerier | |
| 236 | + | // MockCertificateQuerierMockRecorder is the mock recorder for MockCertificateQuerier. | |
| 237 | + | type MockCertificateQuerierMockRecorder struct { | |
| 238 | + | mock *MockCertificateQuerier | |
| 158 | 239 | } | |
| 159 | 240 | ||
| 160 | - | // NewMockShortcutQuerier creates a new mock instance. | |
| 161 | - | func NewMockShortcutQuerier(ctrl *gomock.Controller) *MockShortcutQuerier { | |
| 162 | - | mock := &MockShortcutQuerier{ctrl: ctrl} | |
| 163 | - | mock.recorder = &MockShortcutQuerierMockRecorder{mock} | |
| 241 | + | // NewMockCertificateQuerier creates a new mock instance. | |
| 242 | + | func NewMockCertificateQuerier(ctrl *gomock.Controller) *MockCertificateQuerier { | |
| 243 | + | mock := &MockCertificateQuerier{ctrl: ctrl} | |
| 244 | + | mock.recorder = &MockCertificateQuerierMockRecorder{mock} | |
| 164 | 245 | return mock | |
| 165 | 246 | } | |
| 166 | 247 | ||
| 167 | 248 | // EXPECT returns an object that allows the caller to indicate expected use. | |
| 168 | - | func (m *MockShortcutQuerier) EXPECT() *MockShortcutQuerierMockRecorder { | |
| 249 | + | func (m *MockCertificateQuerier) EXPECT() *MockCertificateQuerierMockRecorder { | |
| 169 | 250 | return m.recorder | |
| 170 | 251 | } | |
| 171 | 252 | ||
| 172 | - | // GetShortcuts mocks base method. | |
| 173 | - | func (m *MockShortcutQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) { | |
| 253 | + | // DeleteCertificateValidation mocks base method. | |
| 254 | + | func (m *MockCertificateQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error { | |
| 174 | 255 | m.ctrl.T.Helper() | |
| 175 | - | ret := m.ctrl.Call(m, "GetShortcuts", ctx) | |
| 176 | - | ret0, _ := ret[0].([]db.Shortcut) | |
| 256 | + | ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash) | |
| 257 | + | ret0, _ := ret[0].(error) | |
| 258 | + | return ret0 | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation. | |
| 262 | + | func (mr *MockCertificateQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |
| 263 | + | mr.mock.ctrl.T.Helper() | |
| 264 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // DeleteExpiredCertificateValidations mocks base method. | |
| 268 | + | func (m *MockCertificateQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error { | |
| 269 | + | m.ctrl.T.Helper() | |
| 270 | + | ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx) | |
| 271 | + | ret0, _ := ret[0].(error) | |
| 272 | + | return ret0 | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations. | |
| 276 | + | func (mr *MockCertificateQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call { | |
| 277 | + | mr.mock.ctrl.T.Helper() | |
| 278 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteExpiredCertificateValidations), ctx) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // GetCertificateValidation mocks base method. | |
| 282 | + | func (m *MockCertificateQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) { | |
| 283 | + | m.ctrl.T.Helper() | |
| 284 | + | ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash) | |
| 285 | + | ret0, _ := ret[0].(db.CertificateValidation) | |
| 286 | + | ret1, _ := ret[1].(error) | |
| 287 | + | return ret0, ret1 | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // GetCertificateValidation indicates an expected call of GetCertificateValidation. | |
| 291 | + | func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |
| 292 | + | mr.mock.ctrl.T.Helper() | |
| 293 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // GetCertificateValidationByHostname mocks base method. | |
| 297 | + | func (m *MockCertificateQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) { | |
| 298 | + | m.ctrl.T.Helper() | |
| 299 | + | ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname) | |
| 300 | + | ret0, _ := ret[0].(db.CertificateValidation) | |
| 301 | + | ret1, _ := ret[1].(error) | |
| 302 | + | return ret0, ret1 | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname. | |
| 306 | + | func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call { | |
| 307 | + | mr.mock.ctrl.T.Helper() | |
| 308 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // ListCertificateValidations mocks base method. | |
| 312 | + | func (m *MockCertificateQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) { | |
| 313 | + | m.ctrl.T.Helper() | |
| 314 | + | ret := m.ctrl.Call(m, "ListCertificateValidations", ctx) | |
| 315 | + | ret0, _ := ret[0].([]db.CertificateValidation) | |
| 177 | 316 | ret1, _ := ret[1].(error) | |
| 178 | 317 | return ret0, ret1 | |
| 179 | 318 | } | |
| 180 | 319 | ||
| 181 | - | // GetShortcuts indicates an expected call of GetShortcuts. | |
| 182 | - | func (mr *MockShortcutQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call { | |
| 320 | + | // ListCertificateValidations indicates an expected call of ListCertificateValidations. | |
| 321 | + | func (mr *MockCertificateQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call { | |
| 322 | + | mr.mock.ctrl.T.Helper() | |
| 323 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).ListCertificateValidations), ctx) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // StoreCertificateValidation mocks base method. | |
| 327 | + | func (m *MockCertificateQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error { | |
| 328 | + | m.ctrl.T.Helper() | |
| 329 | + | ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt) | |
| 330 | + | ret0, _ := ret[0].(error) | |
| 331 | + | return ret0 | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // StoreCertificateValidation indicates an expected call of StoreCertificateValidation. | |
| 335 | + | func (mr *MockCertificateQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call { | |
| 183 | 336 | mr.mock.ctrl.T.Helper() | |
| 184 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockShortcutQuerier)(nil).GetShortcuts), ctx) | |
| 337 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt) | |
| 185 | 338 | } | |
| 186 | 339 | ||
| 187 | 340 | // MockDatabaseQuerier is a mock of DatabaseQuerier interface. | |
| 188 | 341 | type MockDatabaseQuerier struct { | |
| 189 | 342 | ctrl *gomock.Controller | |
| 190 | 343 | recorder *MockDatabaseQuerierMockRecorder | |
| 344 | + | isgomock struct{} | |
| 191 | 345 | } | |
| 192 | 346 | ||
| 193 | 347 | // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier. | |
| @@ -216,11 +370,53 @@ func (m *MockDatabaseQuerier) AddOrUpdateHistory(ctx context.Context, url string | |||
| 216 | 370 | } | |
| 217 | 371 | ||
| 218 | 372 | // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory. | |
| 219 | - | func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call { | |
| 373 | + | func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call { | |
| 220 | 374 | mr.mock.ctrl.T.Helper() | |
| 221 | 375 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title) | |
| 222 | 376 | } | |
| 223 | 377 | ||
| 378 | + | // DeleteCertificateValidation mocks base method. | |
| 379 | + | func (m *MockDatabaseQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error { | |
| 380 | + | m.ctrl.T.Helper() | |
| 381 | + | ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash) | |
| 382 | + | ret0, _ := ret[0].(error) | |
| 383 | + | return ret0 | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation. | |
| 387 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |
| 388 | + | mr.mock.ctrl.T.Helper() | |
| 389 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // DeleteExpiredCertificateValidations mocks base method. | |
| 393 | + | func (m *MockDatabaseQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error { | |
| 394 | + | m.ctrl.T.Helper() | |
| 395 | + | ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx) | |
| 396 | + | ret0, _ := ret[0].(error) | |
| 397 | + | return ret0 | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations. | |
| 401 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call { | |
| 402 | + | mr.mock.ctrl.T.Helper() | |
| 403 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteExpiredCertificateValidations), ctx) | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // DeleteHistory mocks base method. | |
| 407 | + | func (m *MockDatabaseQuerier) DeleteHistory(ctx context.Context, id int64) error { | |
| 408 | + | m.ctrl.T.Helper() | |
| 409 | + | ret := m.ctrl.Call(m, "DeleteHistory", ctx, id) | |
| 410 | + | ret0, _ := ret[0].(error) | |
| 411 | + | return ret0 | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // DeleteHistory indicates an expected call of DeleteHistory. | |
| 415 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call { | |
| 416 | + | mr.mock.ctrl.T.Helper() | |
| 417 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteHistory), ctx, id) | |
| 418 | + | } | |
| 419 | + | ||
| 224 | 420 | // DeleteZoomLevel mocks base method. | |
| 225 | 421 | func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error { | |
| 226 | 422 | m.ctrl.T.Helper() | |
| @@ -230,11 +426,41 @@ func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string | |||
| 230 | 426 | } | |
| 231 | 427 | ||
| 232 | 428 | // DeleteZoomLevel indicates an expected call of DeleteZoomLevel. | |
| 233 | - | func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call { | |
| 429 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call { | |
| 234 | 430 | mr.mock.ctrl.T.Helper() | |
| 235 | 431 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain) | |
| 236 | 432 | } | |
| 237 | 433 | ||
| 434 | + | // GetCertificateValidation mocks base method. | |
| 435 | + | func (m *MockDatabaseQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) { | |
| 436 | + | m.ctrl.T.Helper() | |
| 437 | + | ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash) | |
| 438 | + | ret0, _ := ret[0].(db.CertificateValidation) | |
| 439 | + | ret1, _ := ret[1].(error) | |
| 440 | + | return ret0, ret1 | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // GetCertificateValidation indicates an expected call of GetCertificateValidation. | |
| 444 | + | func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |
| 445 | + | mr.mock.ctrl.T.Helper() | |
| 446 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash) | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // GetCertificateValidationByHostname mocks base method. | |
| 450 | + | func (m *MockDatabaseQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) { | |
| 451 | + | m.ctrl.T.Helper() | |
| 452 | + | ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname) | |
| 453 | + | ret0, _ := ret[0].(db.CertificateValidation) | |
| 454 | + | ret1, _ := ret[1].(error) | |
| 455 | + | return ret0, ret1 | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname. | |
| 459 | + | func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call { | |
| 460 | + | mr.mock.ctrl.T.Helper() | |
| 461 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname) | |
| 462 | + | } | |
| 463 | + | ||
| 238 | 464 | // GetHistory mocks base method. | |
| 239 | 465 | func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) { | |
| 240 | 466 | m.ctrl.T.Helper() | |
| @@ -245,24 +471,39 @@ func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db | |||
| 245 | 471 | } | |
| 246 | 472 | ||
| 247 | 473 | // GetHistory indicates an expected call of GetHistory. | |
| 248 | - | func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call { | |
| 474 | + | func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call { | |
| 249 | 475 | mr.mock.ctrl.T.Helper() | |
| 250 | 476 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit) | |
| 251 | 477 | } | |
| 252 | 478 | ||
| 253 | - | // GetShortcuts mocks base method. | |
| 254 | - | func (m *MockDatabaseQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) { | |
| 479 | + | // GetHistoryEntry mocks base method. | |
| 480 | + | func (m *MockDatabaseQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) { | |
| 255 | 481 | m.ctrl.T.Helper() | |
| 256 | - | ret := m.ctrl.Call(m, "GetShortcuts", ctx) | |
| 257 | - | ret0, _ := ret[0].([]db.Shortcut) | |
| 482 | + | ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url) | |
| 483 | + | ret0, _ := ret[0].(db.History) | |
| 258 | 484 | ret1, _ := ret[1].(error) | |
| 259 | 485 | return ret0, ret1 | |
| 260 | 486 | } | |
| 261 | 487 | ||
| 262 | - | // GetShortcuts indicates an expected call of GetShortcuts. | |
| 263 | - | func (mr *MockDatabaseQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call { | |
| 488 | + | // GetHistoryEntry indicates an expected call of GetHistoryEntry. | |
| 489 | + | func (mr *MockDatabaseQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call { | |
| 264 | 490 | mr.mock.ctrl.T.Helper() | |
| 265 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetShortcuts), ctx) | |
| 491 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryEntry), ctx, url) | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | // GetHistoryWithOffset mocks base method. | |
| 495 | + | func (m *MockDatabaseQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) { | |
| 496 | + | m.ctrl.T.Helper() | |
| 497 | + | ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset) | |
| 498 | + | ret0, _ := ret[0].([]db.History) | |
| 499 | + | ret1, _ := ret[1].(error) | |
| 500 | + | return ret0, ret1 | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset. | |
| 504 | + | func (mr *MockDatabaseQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call { | |
| 505 | + | mr.mock.ctrl.T.Helper() | |
| 506 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset) | |
| 266 | 507 | } | |
| 267 | 508 | ||
| 268 | 509 | // GetZoomLevel mocks base method. | |
| @@ -275,11 +516,41 @@ func (m *MockDatabaseQuerier) GetZoomLevel(ctx context.Context, domain string) ( | |||
| 275 | 516 | } | |
| 276 | 517 | ||
| 277 | 518 | // GetZoomLevel indicates an expected call of GetZoomLevel. | |
| 278 | - | func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call { | |
| 519 | + | func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call { | |
| 279 | 520 | mr.mock.ctrl.T.Helper() | |
| 280 | 521 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain) | |
| 281 | 522 | } | |
| 282 | 523 | ||
| 524 | + | // ListCertificateValidations mocks base method. | |
| 525 | + | func (m *MockDatabaseQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) { | |
| 526 | + | m.ctrl.T.Helper() | |
| 527 | + | ret := m.ctrl.Call(m, "ListCertificateValidations", ctx) | |
| 528 | + | ret0, _ := ret[0].([]db.CertificateValidation) | |
| 529 | + | ret1, _ := ret[1].(error) | |
| 530 | + | return ret0, ret1 | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | // ListCertificateValidations indicates an expected call of ListCertificateValidations. | |
| 534 | + | func (mr *MockDatabaseQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call { | |
| 535 | + | mr.mock.ctrl.T.Helper() | |
| 536 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListCertificateValidations), ctx) | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // ListZoomLevels mocks base method. | |
| 540 | + | func (m *MockDatabaseQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) { | |
| 541 | + | m.ctrl.T.Helper() | |
| 542 | + | ret := m.ctrl.Call(m, "ListZoomLevels", ctx) | |
| 543 | + | ret0, _ := ret[0].([]db.ZoomLevel) | |
| 544 | + | ret1, _ := ret[1].(error) | |
| 545 | + | return ret0, ret1 | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // ListZoomLevels indicates an expected call of ListZoomLevels. | |
| 549 | + | func (mr *MockDatabaseQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call { | |
| 550 | + | mr.mock.ctrl.T.Helper() | |
| 551 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListZoomLevels), ctx) | |
| 552 | + | } | |
| 553 | + | ||
| 283 | 554 | // SearchHistory mocks base method. | |
| 284 | 555 | func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) { | |
| 285 | 556 | m.ctrl.T.Helper() | |
| @@ -290,7 +561,7 @@ func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column | |||
| 290 | 561 | } | |
| 291 | 562 | ||
| 292 | 563 | // SearchHistory indicates an expected call of SearchHistory. | |
| 293 | - | func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call { | |
| 564 | + | func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call { | |
| 294 | 565 | mr.mock.ctrl.T.Helper() | |
| 295 | 566 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit) | |
| 296 | 567 | } | |
| @@ -304,7 +575,35 @@ func (m *MockDatabaseQuerier) SetZoomLevel(ctx context.Context, domain string, z | |||
| 304 | 575 | } | |
| 305 | 576 | ||
| 306 | 577 | // SetZoomLevel indicates an expected call of SetZoomLevel. | |
| 307 | - | func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call { | |
| 578 | + | func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call { | |
| 308 | 579 | mr.mock.ctrl.T.Helper() | |
| 309 | 580 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel) | |
| 310 | 581 | } | |
| 582 | + | ||
| 583 | + | // StoreCertificateValidation mocks base method. | |
| 584 | + | func (m *MockDatabaseQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error { | |
| 585 | + | m.ctrl.T.Helper() | |
| 586 | + | ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt) | |
| 587 | + | ret0, _ := ret[0].(error) | |
| 588 | + | return ret0 | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | // StoreCertificateValidation indicates an expected call of StoreCertificateValidation. | |
| 592 | + | func (mr *MockDatabaseQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call { | |
| 593 | + | mr.mock.ctrl.T.Helper() | |
| 594 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt) | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // UpdateHistoryFavicon mocks base method. | |
| 598 | + | func (m *MockDatabaseQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error { | |
| 599 | + | m.ctrl.T.Helper() | |
| 600 | + | ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url) | |
| 601 | + | ret0, _ := ret[0].(error) | |
| 602 | + | return ret0 | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | // UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon. | |
| 606 | + | func (mr *MockDatabaseQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call { | |
| 607 | + | mr.mock.ctrl.T.Helper() | |
| 608 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockDatabaseQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url) | |
| 609 | + | } | |
M internal/db/models.go +15 -8
| @@ -17,6 +17,21 @@ type CertificateValidation struct { | |||
| 17 | 17 | ExpiresAt sql.NullTime `json:"expires_at"` | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | type FuzzyCacheMetadatum struct { | |
| 21 | + | ID int64 `json:"id"` | |
| 22 | + | Version int64 `json:"version"` | |
| 23 | + | EntryCount int64 `json:"entry_count"` | |
| 24 | + | LastModified int64 `json:"last_modified"` | |
| 25 | + | EntriesHash string `json:"entries_hash"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type FuzzyCacheStructure struct { | |
| 29 | + | ID int64 `json:"id"` | |
| 30 | + | TrigramIndex []byte `json:"trigram_index"` | |
| 31 | + | PrefixTrie []byte `json:"prefix_trie"` | |
| 32 | + | SortedIndex []byte `json:"sorted_index"` | |
| 33 | + | } | |
| 34 | + | ||
| 20 | 35 | type History struct { | |
| 21 | 36 | ID int64 `json:"id"` | |
| 22 | 37 | Url string `json:"url"` | |
| @@ -27,14 +42,6 @@ type History struct { | |||
| 27 | 42 | FaviconUrl sql.NullString `json:"favicon_url"` | |
| 28 | 43 | } | |
| 29 | 44 | ||
| 30 | - | type Shortcut struct { | |
| 31 | - | ID int64 `json:"id"` | |
| 32 | - | Shortcut string `json:"shortcut"` | |
| 33 | - | UrlTemplate string `json:"url_template"` | |
| 34 | - | Description sql.NullString `json:"description"` | |
| 35 | - | CreatedAt sql.NullTime `json:"created_at"` | |
| 36 | - | } | |
| 37 | - | ||
| 38 | 45 | type ZoomLevel struct { | |
| 39 | 46 | Domain string `json:"domain"` | |
| 40 | 47 | ZoomFactor float64 `json:"zoom_factor"` | |
M internal/db/querier.go +1 -1
| @@ -23,11 +23,11 @@ type Querier interface { | |||
| 23 | 23 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 24 | 24 | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 25 | 25 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| 26 | - | GetShortcuts(ctx context.Context) ([]Shortcut, error) | |
| 27 | 26 | // Get zoom level for a specific domain | |
| 28 | 27 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | |
| 29 | 28 | // Get zoom level for domain with default fallback | |
| 30 | 29 | GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error) | |
| 30 | + | ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) | |
| 31 | 31 | // List all zoom level settings ordered by most recently updated | |
| 32 | 32 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |
| 33 | 33 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |
M internal/db/queries/certificate_validations.sql +7 -2
| @@ -20,5 +20,10 @@ DELETE FROM certificate_validations | |||
| 20 | 20 | WHERE expires_at IS NOT NULL AND expires_at <= datetime('now'); | |
| 21 | 21 | ||
| 22 | 22 | -- name: DeleteCertificateValidation :exec | |
| 23 | - | DELETE FROM certificate_validations | |
| 24 | - | WHERE hostname = ? AND certificate_hash = ?;⏎̸ | |
| 23 | + | DELETE FROM certificate_validations | |
| 24 | + | WHERE hostname = ? AND certificate_hash = ?; | |
| 25 | + | ||
| 26 | + | -- name: ListCertificateValidations :many | |
| 27 | + | SELECT * FROM certificate_validations | |
| 28 | + | WHERE (expires_at IS NULL OR expires_at > datetime('now')) | |
| 29 | + | ORDER BY hostname;⏎̸ | |
M internal/db/queries/history.sql +1 -6
| @@ -39,9 +39,4 @@ LIMIT 1; | |||
| 39 | 39 | ||
| 40 | 40 | -- name: DeleteHistory :exec | |
| 41 | 41 | DELETE FROM history | |
| 42 | - | WHERE id = ?; | |
| 43 | - | ||
| 44 | - | -- name: GetShortcuts :many | |
| 45 | - | SELECT id, shortcut, url_template, description, created_at | |
| 46 | - | FROM shortcuts | |
| 47 | - | ORDER BY shortcut;⏎̸ | |
| 42 | + | WHERE id = ?;⏎̸ | |
M internal/filtering/updater.go +11 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/sha256" | |
| 6 | 6 | "fmt" | |
| 7 | + | "log" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| @@ -138,7 +139,11 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi | |||
| 138 | 139 | if err != nil { | |
| 139 | 140 | return nil, fmt.Errorf("HEAD request failed: %w", err) | |
| 140 | 141 | } | |
| 141 | - | defer resp.Body.Close() | |
| 142 | + | defer func() { | |
| 143 | + | if err := resp.Body.Close(); err != nil { | |
| 144 | + | log.Printf("warning: failed to close response body: %v", err) | |
| 145 | + | } | |
| 146 | + | }() | |
| 142 | 147 | ||
| 143 | 148 | if resp.StatusCode != http.StatusOK { | |
| 144 | 149 | return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode) | |
| @@ -183,7 +188,11 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified, | |||
| 183 | 188 | if err != nil { | |
| 184 | 189 | return nil, fmt.Errorf("GET request failed: %w", err) | |
| 185 | 190 | } | |
| 186 | - | defer resp.Body.Close() | |
| 191 | + | defer func() { | |
| 192 | + | if err := resp.Body.Close(); err != nil { | |
| 193 | + | log.Printf("warning: failed to close response body: %v", err) | |
| 194 | + | } | |
| 195 | + | }() | |
| 187 | 196 | ||
| 188 | 197 | if resp.StatusCode != http.StatusOK { | |
| 189 | 198 | return nil, fmt.Errorf("GET request returned %d", resp.StatusCode) | |
M internal/logging/capture.go +37 -12
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "log" | |
| 7 | 8 | "os" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -46,8 +47,12 @@ func (c *OutputCapture) Start() error { | |||
| 46 | 47 | // Create pipes for stderr | |
| 47 | 48 | stderrR, stderrW, err := os.Pipe() | |
| 48 | 49 | if err != nil { | |
| 49 | - | stdoutR.Close() | |
| 50 | - | stdoutW.Close() | |
| 50 | + | if closeErr := stdoutR.Close(); closeErr != nil { | |
| 51 | + | log.Printf("failed to close stdout read pipe: %v", closeErr) | |
| 52 | + | } | |
| 53 | + | if closeErr := stdoutW.Close(); closeErr != nil { | |
| 54 | + | log.Printf("failed to close stdout write pipe: %v", closeErr) | |
| 55 | + | } | |
| 51 | 56 | return err | |
| 52 | 57 | } | |
| 53 | 58 | ||
| @@ -62,8 +67,12 @@ func (c *OutputCapture) Start() error { | |||
| 62 | 67 | os.Stderr = stderrW | |
| 63 | 68 | ||
| 64 | 69 | // Also redirect file descriptors at syscall level for C code | |
| 65 | - | unix.Dup3(int(stdoutW.Fd()), 1, 0) | |
| 66 | - | unix.Dup3(int(stderrW.Fd()), 2, 0) | |
| 70 | + | if err := unix.Dup3(int(stdoutW.Fd()), 1, 0); err != nil { | |
| 71 | + | log.Printf("failed to redirect stdout: %v", err) | |
| 72 | + | } | |
| 73 | + | if err := unix.Dup3(int(stderrW.Fd()), 2, 0); err != nil { | |
| 74 | + | log.Printf("failed to redirect stderr: %v", err) | |
| 75 | + | } | |
| 67 | 76 | ||
| 68 | 77 | // Start goroutines to read and log | |
| 69 | 78 | go c.pipeToLogger(stdoutR, "STDOUT") | |
| @@ -85,21 +94,33 @@ func (c *OutputCapture) Stop() { | |||
| 85 | 94 | os.Stderr = c.originalStderr | |
| 86 | 95 | ||
| 87 | 96 | // Restore file descriptors | |
| 88 | - | unix.Dup3(int(c.originalStdout.Fd()), 1, 0) | |
| 89 | - | unix.Dup3(int(c.originalStderr.Fd()), 2, 0) | |
| 97 | + | if err := unix.Dup3(int(c.originalStdout.Fd()), 1, 0); err != nil { | |
| 98 | + | log.Printf("failed to restore stdout: %v", err) | |
| 99 | + | } | |
| 100 | + | if err := unix.Dup3(int(c.originalStderr.Fd()), 2, 0); err != nil { | |
| 101 | + | log.Printf("failed to restore stderr: %v", err) | |
| 102 | + | } | |
| 90 | 103 | ||
| 91 | 104 | // Close pipes | |
| 92 | 105 | if c.stdoutWrite != nil { | |
| 93 | - | c.stdoutWrite.Close() | |
| 106 | + | if err := c.stdoutWrite.Close(); err != nil { | |
| 107 | + | log.Printf("failed to close stdout write pipe: %v", err) | |
| 108 | + | } | |
| 94 | 109 | } | |
| 95 | 110 | if c.stderrWrite != nil { | |
| 96 | - | c.stderrWrite.Close() | |
| 111 | + | if err := c.stderrWrite.Close(); err != nil { | |
| 112 | + | log.Printf("failed to close stderr write pipe: %v", err) | |
| 113 | + | } | |
| 97 | 114 | } | |
| 98 | 115 | if c.stdoutRead != nil { | |
| 99 | - | c.stdoutRead.Close() | |
| 116 | + | if err := c.stdoutRead.Close(); err != nil { | |
| 117 | + | log.Printf("failed to close stdout read pipe: %v", err) | |
| 118 | + | } | |
| 100 | 119 | } | |
| 101 | 120 | if c.stderrRead != nil { | |
| 102 | - | c.stderrRead.Close() | |
| 121 | + | if err := c.stderrRead.Close(); err != nil { | |
| 122 | + | log.Printf("failed to close stderr read pipe: %v", err) | |
| 123 | + | } | |
| 103 | 124 | } | |
| 104 | 125 | ||
| 105 | 126 | c.started = false | |
| @@ -121,9 +142,13 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) { | |||
| 121 | 142 | timestamp := time.Now().Format("2006-01-02 15:04:05") | |
| 122 | 143 | logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line) | |
| 123 | 144 | if prefix == "STDERR" { | |
| 124 | - | c.originalStderr.WriteString(logLine) | |
| 145 | + | if _, err := c.originalStderr.WriteString(logLine); err != nil { | |
| 146 | + | log.Printf("failed to write to stderr: %v", err) | |
| 147 | + | } | |
| 125 | 148 | } else { | |
| 126 | - | c.originalStdout.WriteString(logLine) | |
| 149 | + | if _, err := c.originalStdout.WriteString(logLine); err != nil { | |
| 150 | + | log.Printf("failed to write to stdout: %v", err) | |
| 151 | + | } | |
| 127 | 152 | } | |
| 128 | 153 | } | |
| 129 | 154 | } | |
M internal/logging/crash.go +3 -1
| @@ -67,7 +67,9 @@ func handleCrash(sig os.Signal) { | |||
| 67 | 67 | ||
| 68 | 68 | // Force flush logs | |
| 69 | 69 | if logger.rotator != nil { | |
| 70 | - | logger.rotator.Close() | |
| 70 | + | if err := logger.rotator.Close(); err != nil { | |
| 71 | + | fmt.Fprintf(os.Stderr, "warning: failed to close log rotator: %v\n", err) | |
| 72 | + | } | |
| 71 | 73 | } | |
| 72 | 74 | ||
| 73 | 75 | // Exit with error code | |
M internal/logging/logger.go +7 -3
| @@ -97,7 +97,7 @@ func Init(logDir string, level string, format string, enableFileLog bool, maxSiz | |||
| 97 | 97 | globalLoggerMux.Lock() | |
| 98 | 98 | defer globalLoggerMux.Unlock() | |
| 99 | 99 | ||
| 100 | - | err := os.MkdirAll(logDir, 0755) | |
| 100 | + | err := os.MkdirAll(logDir, 0750) | |
| 101 | 101 | if err != nil { | |
| 102 | 102 | return fmt.Errorf("failed to create log directory: %w", err) | |
| 103 | 103 | } | |
| @@ -158,7 +158,9 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) { | |||
| 158 | 158 | formatted := l.formatter.Format(level, message, source) | |
| 159 | 159 | ||
| 160 | 160 | for _, output := range l.outputs { | |
| 161 | - | output.Write([]byte(formatted)) | |
| 161 | + | if _, err := output.Write([]byte(formatted)); err != nil { | |
| 162 | + | fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err) | |
| 163 | + | } | |
| 162 | 164 | } | |
| 163 | 165 | } | |
| 164 | 166 | ||
| @@ -180,7 +182,9 @@ func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) { | |||
| 180 | 182 | // Write only to file outputs (skip stdout) | |
| 181 | 183 | for _, output := range l.outputs { | |
| 182 | 184 | if output != os.Stdout { | |
| 183 | - | output.Write([]byte(formatted)) | |
| 185 | + | if _, err := output.Write([]byte(formatted)); err != nil { | |
| 186 | + | fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err) | |
| 187 | + | } | |
| 184 | 188 | } | |
| 185 | 189 | } | |
| 186 | 190 | } | |
M internal/logging/rotator.go +28 -8
| @@ -49,7 +49,7 @@ func (r *LogRotator) openCurrentFile() error { | |||
| 49 | 49 | r.currentSize = info.Size() | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | |
| 52 | + | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) | |
| 53 | 53 | if err != nil { | |
| 54 | 54 | return fmt.Errorf("failed to open log file: %w", err) | |
| 55 | 55 | } | |
| @@ -86,7 +86,9 @@ func (r *LogRotator) Write(p []byte) (n int, err error) { | |||
| 86 | 86 | ||
| 87 | 87 | func (r *LogRotator) rotate() error { | |
| 88 | 88 | if r.currentFile != nil { | |
| 89 | - | r.currentFile.Close() | |
| 89 | + | if err := r.currentFile.Close(); err != nil { | |
| 90 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close current log file: %v\n", err) | |
| 91 | + | } | |
| 90 | 92 | } | |
| 91 | 93 | ||
| 92 | 94 | // Generate backup filename with timestamp | |
| @@ -108,7 +110,9 @@ func (r *LogRotator) rotate() error { | |||
| 108 | 110 | fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err) | |
| 109 | 111 | } else { | |
| 110 | 112 | // Remove uncompressed file after successful compression | |
| 111 | - | os.Remove(backupPath) | |
| 113 | + | if err := os.Remove(backupPath); err != nil { | |
| 114 | + | fmt.Fprintf(os.Stderr, "Warning: failed to remove uncompressed log file %s: %v\n", backupPath, err) | |
| 115 | + | } | |
| 112 | 116 | } | |
| 113 | 117 | } | |
| 114 | 118 | ||
| @@ -125,17 +129,29 @@ func (r *LogRotator) compressFile(filePath string) error { | |||
| 125 | 129 | if err != nil { | |
| 126 | 130 | return err | |
| 127 | 131 | } | |
| 128 | - | defer inputFile.Close() | |
| 132 | + | defer func() { | |
| 133 | + | if err := inputFile.Close(); err != nil { | |
| 134 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close input file during compression: %v\n", err) | |
| 135 | + | } | |
| 136 | + | }() | |
| 129 | 137 | ||
| 130 | 138 | outputPath := filePath + ".gz" | |
| 131 | 139 | outputFile, err := os.Create(outputPath) | |
| 132 | 140 | if err != nil { | |
| 133 | 141 | return err | |
| 134 | 142 | } | |
| 135 | - | defer outputFile.Close() | |
| 143 | + | defer func() { | |
| 144 | + | if err := outputFile.Close(); err != nil { | |
| 145 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close output file during compression: %v\n", err) | |
| 146 | + | } | |
| 147 | + | }() | |
| 136 | 148 | ||
| 137 | 149 | gzipWriter := gzip.NewWriter(outputFile) | |
| 138 | - | defer gzipWriter.Close() | |
| 150 | + | defer func() { | |
| 151 | + | if err := gzipWriter.Close(); err != nil { | |
| 152 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close gzip writer: %v\n", err) | |
| 153 | + | } | |
| 154 | + | }() | |
| 139 | 155 | ||
| 140 | 156 | _, err = io.Copy(gzipWriter, inputFile) | |
| 141 | 157 | return err | |
| @@ -167,7 +183,9 @@ func (r *LogRotator) cleanup() { | |||
| 167 | 183 | ||
| 168 | 184 | // Remove files older than maxAge | |
| 169 | 185 | if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge { | |
| 170 | - | os.Remove(filepath.Join(r.baseDir, name)) | |
| 186 | + | if err := os.Remove(filepath.Join(r.baseDir, name)); err != nil { | |
| 187 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove old log file: %v\n", err) | |
| 188 | + | } | |
| 171 | 189 | continue | |
| 172 | 190 | } | |
| 173 | 191 | ||
| @@ -183,7 +201,9 @@ func (r *LogRotator) cleanup() { | |||
| 183 | 201 | ||
| 184 | 202 | // Remove oldest files | |
| 185 | 203 | for i := 0; i < len(backupFiles)-r.maxBackups; i++ { | |
| 186 | - | os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())) | |
| 204 | + | if err := os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())); err != nil { | |
| 205 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove excess backup file: %v\n", err) | |
| 206 | + | } | |
| 187 | 207 | } | |
| 188 | 208 | } | |
| 189 | 209 | } | |
A internal/migrations/006_add_fuzzy_cache_tables.sql +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | -- Database consolidation: Move fuzzy cache from binary file to database | |
| 2 | + | -- Replaces ~/.local/state/dumber/dmenu_fuzzy_cache.bin with proper SQL tables | |
| 3 | + | -- This enables single-database architecture: dumber.sqlite | |
| 4 | + | ||
| 5 | + | -- Metadata table: stores cache statistics and versioning | |
| 6 | + | CREATE TABLE IF NOT EXISTS fuzzy_cache_metadata ( | |
| 7 | + | id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed | |
| 8 | + | version INTEGER NOT NULL, | |
| 9 | + | entry_count INTEGER NOT NULL, | |
| 10 | + | last_modified INTEGER NOT NULL, -- Unix timestamp | |
| 11 | + | entries_hash TEXT NOT NULL -- MD5 hash for cache invalidation detection | |
| 12 | + | ); | |
| 13 | + | ||
| 14 | + | -- Structures table: stores serialized fuzzy search data structures | |
| 15 | + | CREATE TABLE IF NOT EXISTS fuzzy_cache_structures ( | |
| 16 | + | id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed | |
| 17 | + | trigram_index BLOB NOT NULL, -- Serialized trigram index: map[string][]int | |
| 18 | + | prefix_trie BLOB NOT NULL, -- Serialized prefix trie for fast lookups | |
| 19 | + | sorted_index BLOB NOT NULL -- Pre-sorted entries for ranking | |
| 20 | + | ); | |
| 21 | + | ||
| 22 | + | -- Note: No indexes needed - singleton tables with single row access pattern | |
A internal/migrations/007_remove_shortcuts_table.sql +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | -- Remove shortcuts table | |
| 2 | + | -- Shortcuts are now managed exclusively through configuration files | |
| 3 | + | -- This table was redundant as shortcuts were synced from config on startup | |
| 4 | + | -- but never queried during runtime (parser reads from config directly) | |
| 5 | + | ||
| 6 | + | DROP TABLE IF EXISTS shortcuts; | |
M internal/migrations/migrations.go +8 -2
| @@ -132,7 +132,9 @@ func applyMigration(db *sql.DB, migration Migration) error { | |||
| 132 | 132 | } | |
| 133 | 133 | defer func() { | |
| 134 | 134 | if err != nil { | |
| 135 | - | tx.Rollback() | |
| 135 | + | if rollbackErr := tx.Rollback(); rollbackErr != nil { | |
| 136 | + | log.Printf("failed to rollback migration transaction: %v", rollbackErr) | |
| 137 | + | } | |
| 136 | 138 | } | |
| 137 | 139 | }() | |
| 138 | 140 | ||
| @@ -175,7 +177,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |||
| 175 | 177 | if err != nil { | |
| 176 | 178 | return nil, fmt.Errorf("failed to query applied migrations: %w", err) | |
| 177 | 179 | } | |
| 178 | - | defer rows.Close() | |
| 180 | + | defer func() { | |
| 181 | + | if err := rows.Close(); err != nil { | |
| 182 | + | log.Printf("failed to close migration query rows: %v", err) | |
| 183 | + | } | |
| 184 | + | }() | |
| 179 | 185 | ||
| 180 | 186 | var versions []int | |
| 181 | 187 | for rows.Next() { | |
M internal/migrations/migrations_test.go +13 -13
| @@ -20,7 +20,7 @@ func TestMigrationsAreIdempotent(t *testing.T) { | |||
| 20 | 20 | if err != nil { | |
| 21 | 21 | t.Fatalf("Failed to open test database: %v", err) | |
| 22 | 22 | } | |
| 23 | - | defer db.Close() | |
| 23 | + | defer func() { _ = db.Close() }() | |
| 24 | 24 | ||
| 25 | 25 | // Run migrations first time | |
| 26 | 26 | if err := RunEmbeddedMigrations(db); err != nil { | |
| @@ -83,7 +83,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) { | |||
| 83 | 83 | if err != nil { | |
| 84 | 84 | t.Fatalf("Failed to open test database: %v", err) | |
| 85 | 85 | } | |
| 86 | - | defer db.Close() | |
| 86 | + | defer func() { _ = db.Close() }() | |
| 87 | 87 | ||
| 88 | 88 | // Run migrations | |
| 89 | 89 | if err := RunEmbeddedMigrations(db); err != nil { | |
| @@ -106,7 +106,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) { | |||
| 106 | 106 | if err != nil { | |
| 107 | 107 | t.Fatalf("Failed to check for duplicate migration entries: %v", err) | |
| 108 | 108 | } | |
| 109 | - | defer rows.Close() | |
| 109 | + | defer func() { _ = rows.Close() }() | |
| 110 | 110 | ||
| 111 | 111 | duplicates := []int{} | |
| 112 | 112 | for rows.Next() { | |
| @@ -131,7 +131,7 @@ func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) { | |||
| 131 | 131 | if err != nil { | |
| 132 | 132 | t.Fatalf("Failed to open test database: %v", err) | |
| 133 | 133 | } | |
| 134 | - | defer db.Close() | |
| 134 | + | defer func() { _ = db.Close() }() | |
| 135 | 135 | ||
| 136 | 136 | // Get expected migrations | |
| 137 | 137 | expectedMigrations, err := GetMigrations() | |
| @@ -207,7 +207,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) { | |||
| 207 | 207 | if err != nil { | |
| 208 | 208 | t.Fatalf("Failed to open test database: %v", err) | |
| 209 | 209 | } | |
| 210 | - | defer db.Close() | |
| 210 | + | defer func() { _ = db.Close() }() | |
| 211 | 211 | ||
| 212 | 212 | // Create tracking table multiple times | |
| 213 | 213 | for i := 0; i < 3; i++ { | |
| @@ -236,7 +236,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) { | |||
| 236 | 236 | if err != nil { | |
| 237 | 237 | t.Fatalf("Failed to get table info: %v", err) | |
| 238 | 238 | } | |
| 239 | - | defer rows.Close() | |
| 239 | + | defer func() { _ = rows.Close() }() | |
| 240 | 240 | ||
| 241 | 241 | expectedColumns := map[string]bool{ | |
| 242 | 242 | "version": false, | |
| @@ -275,7 +275,7 @@ func TestPartialMigrationState(t *testing.T) { | |||
| 275 | 275 | if err != nil { | |
| 276 | 276 | t.Fatalf("Failed to open test database: %v", err) | |
| 277 | 277 | } | |
| 278 | - | defer db.Close() | |
| 278 | + | defer func() { _ = db.Close() }() | |
| 279 | 279 | ||
| 280 | 280 | // Create tracking table | |
| 281 | 281 | if err := createMigrationsTable(db); err != nil { | |
| @@ -300,12 +300,12 @@ func TestPartialMigrationState(t *testing.T) { | |||
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | 302 | if _, err := tx.Exec(firstMigration.SQL); err != nil { | |
| 303 | - | tx.Rollback() | |
| 303 | + | _ = tx.Rollback() | |
| 304 | 304 | t.Fatalf("Failed to execute first migration: %v", err) | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | 307 | if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil { | |
| 308 | - | tx.Rollback() | |
| 308 | + | _ = tx.Rollback() | |
| 309 | 309 | t.Fatalf("Failed to record first migration: %v", err) | |
| 310 | 310 | } | |
| 311 | 311 | ||
| @@ -353,7 +353,7 @@ func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) { | |||
| 353 | 353 | if err != nil { | |
| 354 | 354 | t.Fatalf("Failed to open test database: %v", err) | |
| 355 | 355 | } | |
| 356 | - | defer db.Close() | |
| 356 | + | defer func() { _ = db.Close() }() | |
| 357 | 357 | ||
| 358 | 358 | // Query applied migrations on empty database | |
| 359 | 359 | applied, err := GetAppliedMigrations(db) | |
| @@ -375,7 +375,7 @@ func TestVerifyAllMigrationsApplied(t *testing.T) { | |||
| 375 | 375 | if err != nil { | |
| 376 | 376 | t.Fatalf("Failed to open test database: %v", err) | |
| 377 | 377 | } | |
| 378 | - | defer db.Close() | |
| 378 | + | defer func() { _ = db.Close() }() | |
| 379 | 379 | ||
| 380 | 380 | // Run migrations | |
| 381 | 381 | if err := RunEmbeddedMigrations(db); err != nil { | |
| @@ -409,7 +409,7 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) { | |||
| 409 | 409 | if err != nil { | |
| 410 | 410 | t.Fatalf("Failed to open test database: %v", err) | |
| 411 | 411 | } | |
| 412 | - | defer db.Close() | |
| 412 | + | defer func() { _ = db.Close() }() | |
| 413 | 413 | ||
| 414 | 414 | // Run migrations | |
| 415 | 415 | if err := RunEmbeddedMigrations(db); err != nil { | |
| @@ -420,9 +420,9 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) { | |||
| 420 | 420 | expectedTables := []string{ | |
| 421 | 421 | "schema_migrations", // Migration tracking | |
| 422 | 422 | "history", // 001_initial.sql | |
| 423 | - | "shortcuts", // 001_initial.sql | |
| 424 | 423 | "zoom_levels", // 003_add_zoom_levels.sql | |
| 425 | 424 | "certificate_validations", // 005_add_certificate_validations.sql | |
| 425 | + | // Note: shortcuts table removed in 007_remove_shortcuts_table.sql | |
| 426 | 426 | } | |
| 427 | 427 | ||
| 428 | 428 | // Check each expected table exists | |
M internal/services/browser.go +219 -23
| @@ -5,13 +5,14 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "database/sql" | |
| 7 | 7 | "embed" | |
| 8 | - | "errors" | |
| 9 | 8 | "fmt" | |
| 10 | 9 | "log" | |
| 11 | 10 | "math" | |
| 12 | 11 | neturl "net/url" | |
| 12 | + | "sync" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/dumber/internal/cache" | |
| 15 | 16 | "github.com/bnema/dumber/internal/config" | |
| 16 | 17 | "github.com/bnema/dumber/internal/db" | |
| 17 | 18 | "github.com/bnema/dumber/pkg/webkit" | |
| @@ -33,6 +34,12 @@ type WindowTitleUpdater interface { | |||
| 33 | 34 | SetTitle(title string) | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 37 | + | // historyUpdate represents a pending history write operation | |
| 38 | + | type historyUpdate struct { | |
| 39 | + | url string | |
| 40 | + | title sql.NullString | |
| 41 | + | } | |
| 42 | + | ||
| 36 | 43 | // BrowserService handles browser-related operations for the built-in browser. | |
| 37 | 44 | type BrowserService struct { | |
| 38 | 45 | config *config.Config | |
| @@ -41,6 +48,16 @@ type BrowserService struct { | |||
| 41 | 48 | webView *webkit.WebView | |
| 42 | 49 | initialURL string | |
| 43 | 50 | guiBundle string | |
| 51 | + | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | |
| 52 | + | certCache *cache.CertValidationsCache // In-memory cache for certificate validations | |
| 53 | + | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu | |
| 54 | + | historyQueue chan historyUpdate // Queue for batched history writes | |
| 55 | + | historyFlushDone chan bool // Signal when history flush is complete (buffered) | |
| 56 | + | flushOnce sync.Once // Ensures FlushHistoryQueue only runs once | |
| 57 | + | ||
| 58 | + | // Fuzzy cache refresh tracking | |
| 59 | + | historySinceRefresh int // Count of history entries added since last cache refresh | |
| 60 | + | lastCacheRefresh time.Time // Timestamp of last fuzzy cache refresh | |
| 44 | 61 | } | |
| 45 | 62 | ||
| 46 | 63 | // ServiceName returns the service name for frontend binding | |
| @@ -71,14 +88,26 @@ type HistoryEntry struct { | |||
| 71 | 88 | ||
| 72 | 89 | // NewBrowserService creates a new BrowserService instance. | |
| 73 | 90 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | |
| 74 | - | return &BrowserService{ | |
| 75 | - | config: cfg, | |
| 76 | - | dbQueries: queries, | |
| 77 | - | windowTitleUpdater: nil, | |
| 78 | - | webView: nil, | |
| 79 | - | initialURL: "", | |
| 80 | - | guiBundle: "", | |
| 91 | + | s := &BrowserService{ | |
| 92 | + | config: cfg, | |
| 93 | + | dbQueries: queries, | |
| 94 | + | windowTitleUpdater: nil, | |
| 95 | + | webView: nil, | |
| 96 | + | initialURL: "", | |
| 97 | + | guiBundle: "", | |
| 98 | + | zoomCache: cache.NewZoomCache(queries), | |
| 99 | + | certCache: cache.NewCertValidationsCache(queries), | |
| 100 | + | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | |
| 101 | + | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |
| 102 | + | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |
| 103 | + | historySinceRefresh: 0, | |
| 104 | + | lastCacheRefresh: time.Now(), // Initialize to now | |
| 81 | 105 | } | |
| 106 | + | ||
| 107 | + | // Start background batch processor for history writes | |
| 108 | + | go s.processHistoryQueue() | |
| 109 | + | ||
| 110 | + | return s | |
| 82 | 111 | } | |
| 83 | 112 | ||
| 84 | 113 | // SetWindowTitleUpdater sets the window title updater interface | |
| @@ -359,14 +388,113 @@ func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]con | |||
| 359 | 388 | } | |
| 360 | 389 | ||
| 361 | 390 | // recordHistory adds or updates a history entry. | |
| 391 | + | // Now queues the update for batched processing instead of immediate DB write. | |
| 362 | 392 | func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error { | |
| 363 | - | // Use AddOrUpdateHistory which handles both cases | |
| 364 | 393 | titleNull := sql.NullString{Valid: false} | |
| 365 | 394 | if title != "" { | |
| 366 | 395 | titleNull = sql.NullString{String: title, Valid: true} | |
| 367 | 396 | } | |
| 368 | 397 | ||
| 369 | - | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |
| 398 | + | // Queue the history update (non-blocking with buffer) | |
| 399 | + | select { | |
| 400 | + | case s.historyQueue <- historyUpdate{url: url, title: titleNull}: | |
| 401 | + | // Successfully queued | |
| 402 | + | return nil | |
| 403 | + | default: | |
| 404 | + | // Queue full, write directly (fallback) | |
| 405 | + | log.Printf("Warning: history queue full, writing directly") | |
| 406 | + | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // processHistoryQueue processes batched history writes in the background. | |
| 411 | + | // Flushes every 5 seconds or when a batch reaches 50 items. | |
| 412 | + | func (s *BrowserService) processHistoryQueue() { | |
| 413 | + | ticker := time.NewTicker(5 * time.Second) | |
| 414 | + | defer ticker.Stop() | |
| 415 | + | ||
| 416 | + | batch := make([]historyUpdate, 0, 50) | |
| 417 | + | const maxBatchSize = 50 | |
| 418 | + | ||
| 419 | + | flush := func() { | |
| 420 | + | if len(batch) == 0 { | |
| 421 | + | return | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | ctx := context.Background() | |
| 425 | + | // Process all updates in the batch | |
| 426 | + | // Note: We can't use a transaction with sqlc-generated code easily, | |
| 427 | + | // but we can still batch the operations to reduce overhead | |
| 428 | + | for _, update := range batch { | |
| 429 | + | if err := s.dbQueries.AddOrUpdateHistory(ctx, update.url, update.title); err != nil { | |
| 430 | + | log.Printf("Warning: failed to write history for %s: %v", update.url, err) | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | log.Printf("Flushed %d history updates to database", len(batch)) | |
| 435 | + | ||
| 436 | + | // Track history entries for fuzzy cache refresh | |
| 437 | + | batchSize := len(batch) | |
| 438 | + | batch = batch[:0] // Clear batch | |
| 439 | + | ||
| 440 | + | // Smart fuzzy cache refresh: rebuild after N entries AND M minutes | |
| 441 | + | const minEntries = 10 // Minimum new entries before refresh | |
| 442 | + | const minInterval = 5 * time.Minute // Minimum time between refreshes | |
| 443 | + | ||
| 444 | + | s.historySinceRefresh += batchSize | |
| 445 | + | timeSinceRefresh := time.Since(s.lastCacheRefresh) | |
| 446 | + | ||
| 447 | + | if s.historySinceRefresh >= minEntries && timeSinceRefresh >= minInterval { | |
| 448 | + | log.Printf("[cache] Triggering async fuzzy cache refresh (%d new entries, %v since last refresh)", | |
| 449 | + | s.historySinceRefresh, timeSinceRefresh) | |
| 450 | + | ||
| 451 | + | // Async refresh in background (non-blocking) | |
| 452 | + | s.fuzzyCache.InvalidateAndRefresh(ctx) | |
| 453 | + | ||
| 454 | + | // Reset counters | |
| 455 | + | s.historySinceRefresh = 0 | |
| 456 | + | s.lastCacheRefresh = time.Now() | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | for { | |
| 461 | + | select { | |
| 462 | + | case update, ok := <-s.historyQueue: | |
| 463 | + | if !ok { | |
| 464 | + | // Channel closed, flush remaining and exit | |
| 465 | + | flush() | |
| 466 | + | s.historyFlushDone <- true // Send signal (non-blocking due to buffer) | |
| 467 | + | return | |
| 468 | + | } | |
| 469 | + | batch = append(batch, update) | |
| 470 | + | if len(batch) >= maxBatchSize { | |
| 471 | + | flush() | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | case <-ticker.C: | |
| 475 | + | flush() | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // FlushHistoryQueue flushes any pending history writes and stops the processor. | |
| 481 | + | // Call this during graceful shutdown. Safe to call multiple times. | |
| 482 | + | // Respects context cancellation with a timeout to prevent hanging. | |
| 483 | + | func (s *BrowserService) FlushHistoryQueue(ctx context.Context) error { | |
| 484 | + | var flushErr error | |
| 485 | + | s.flushOnce.Do(func() { | |
| 486 | + | close(s.historyQueue) | |
| 487 | + | ||
| 488 | + | // Wait for completion with context cancellation support | |
| 489 | + | select { | |
| 490 | + | case <-s.historyFlushDone: | |
| 491 | + | log.Printf("History queue flushed and closed") | |
| 492 | + | case <-ctx.Done(): | |
| 493 | + | flushErr = fmt.Errorf("history queue flush cancelled: %w", ctx.Err()) | |
| 494 | + | log.Printf("Warning: %v", flushErr) | |
| 495 | + | } | |
| 496 | + | }) | |
| 497 | + | return flushErr | |
| 370 | 498 | } | |
| 371 | 499 | ||
| 372 | 500 | // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500% | |
| @@ -409,31 +537,97 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||
| 409 | 537 | return s.setZoom(ctx, url, 1.0) | |
| 410 | 538 | } | |
| 411 | 539 | ||
| 540 | + | // LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup. | |
| 541 | + | // This eliminates database reads during page transitions. | |
| 542 | + | func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error { | |
| 543 | + | if err := s.zoomCache.Load(ctx); err != nil { | |
| 544 | + | return fmt.Errorf("failed to load zoom levels: %w", err) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | loadedCount := len(s.zoomCache.List()) | |
| 548 | + | log.Printf("Loaded %d zoom levels into cache", loadedCount) | |
| 549 | + | return nil | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | // LoadCertCacheFromDB loads all certificate validations from the database into the cache. | |
| 553 | + | // This is typically called once at startup for fast RAM-first access. | |
| 554 | + | func (s *BrowserService) LoadCertCacheFromDB(ctx context.Context) error { | |
| 555 | + | if err := s.certCache.Load(ctx); err != nil { | |
| 556 | + | return fmt.Errorf("failed to load certificate validations: %w", err) | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | loadedCount := len(s.certCache.List()) | |
| 560 | + | log.Printf("Loaded %d certificate validations into cache", loadedCount) | |
| 561 | + | return nil | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | // LoadFuzzyCacheFromDB builds or loads the fuzzy search cache from database history. | |
| 565 | + | // This ensures dmenu has instant access to pre-built search structures. | |
| 566 | + | func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |
| 567 | + | // GetCache automatically loads from binary file if valid, or builds from DB | |
| 568 | + | _, err := s.fuzzyCache.GetCache(ctx) | |
| 569 | + | if err != nil { | |
| 570 | + | return fmt.Errorf("failed to load fuzzy cache: %w", err) | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | stats := s.fuzzyCache.Stats() | |
| 574 | + | log.Printf("Loaded fuzzy cache: %d entries, %d trigrams", | |
| 575 | + | stats.EntryCount, stats.TrigramCount) | |
| 576 | + | return nil | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // FlushAllCaches flushes all in-memory caches to ensure pending writes complete. | |
| 580 | + | // This is typically called during shutdown to prevent data loss. | |
| 581 | + | func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |
| 582 | + | // Flush zoom cache | |
| 583 | + | if err := s.zoomCache.Flush(); err != nil { | |
| 584 | + | return fmt.Errorf("failed to flush zoom cache: %w", err) | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | // Flush certificate validation cache | |
| 588 | + | if err := s.certCache.Flush(); err != nil { | |
| 589 | + | return fmt.Errorf("failed to flush cert cache: %w", err) | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // Save fuzzy cache to binary file for instant dmenu access | |
| 593 | + | s.fuzzyCache.OnApplicationExit(ctx) | |
| 594 | + | ||
| 595 | + | // Flush history queue (existing mechanism) | |
| 596 | + | if err := s.FlushHistoryQueue(ctx); err != nil { | |
| 597 | + | return fmt.Errorf("failed to flush history queue: %w", err) | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | log.Printf("All caches flushed successfully") | |
| 601 | + | return nil | |
| 602 | + | } | |
| 603 | + | ||
| 412 | 604 | // GetZoomLevel retrieves the saved zoom level for a URL. | |
| 605 | + | // Now reads from in-memory cache instead of database for instant access. | |
| 413 | 606 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | |
| 414 | 607 | if url == "" { | |
| 415 | 608 | return s.config.DefaultZoom, nil | |
| 416 | 609 | } | |
| 417 | 610 | ||
| 418 | 611 | key := zoomKeyFromURL(url) | |
| 419 | - | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) | |
| 420 | - | if err != nil { | |
| 421 | - | if errors.Is(err, sql.ErrNoRows) { | |
| 422 | - | // Fallback to exact URL record if present (backward compatibility) | |
| 423 | - | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) | |
| 424 | - | if err2 == nil { | |
| 425 | - | return zl, nil | |
| 426 | - | } | |
| 427 | - | // No zoom setting found, return configured default | |
| 428 | - | return s.config.DefaultZoom, nil | |
| 612 | + | ||
| 613 | + | // Check cache first (fast RAM lookup) | |
| 614 | + | if zoom, ok := s.zoomCache.Get(key); ok { | |
| 615 | + | return zoom, nil | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | // Fallback to exact URL record for backward compatibility (cache miss) | |
| 619 | + | if key != url { | |
| 620 | + | if zoom, ok := s.zoomCache.Get(url); ok { | |
| 621 | + | return zoom, nil | |
| 429 | 622 | } | |
| 430 | - | return s.config.DefaultZoom, err | |
| 431 | 623 | } | |
| 432 | 624 | ||
| 433 | - | return zoomLevel, nil | |
| 625 | + | // No zoom setting found, return configured default | |
| 626 | + | return s.config.DefaultZoom, nil | |
| 434 | 627 | } | |
| 435 | 628 | ||
| 436 | 629 | // SetZoomLevel sets the zoom level for a URL. | |
| 630 | + | // Updates cache immediately (instant) and persists to DB asynchronously. | |
| 437 | 631 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | |
| 438 | 632 | if url == "" { | |
| 439 | 633 | return fmt.Errorf("URL cannot be empty") | |
| @@ -447,7 +641,9 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel | |||
| 447 | 641 | } | |
| 448 | 642 | ||
| 449 | 643 | key := zoomKeyFromURL(url) | |
| 450 | - | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) | |
| 644 | + | ||
| 645 | + | // Update cache immediately (fast, synchronous) and persist async | |
| 646 | + | return s.zoomCache.Set(key, zoomLevel) | |
| 451 | 647 | } | |
| 452 | 648 | ||
| 453 | 649 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | |
A internal/services/browser_fuzzy_test.go +312 -0
| @@ -0,0 +1,312 @@ | |||
| 1 | + | package services | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 10 | + | "github.com/bnema/dumber/internal/db" | |
| 11 | + | mock_db "github.com/bnema/dumber/internal/db/mocks" | |
| 12 | + | "go.uber.org/mock/gomock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // TestBrowserService_FuzzyCacheIntegration tests the fuzzy cache lifecycle in BrowserService | |
| 16 | + | func TestBrowserService_FuzzyCacheIntegration(t *testing.T) { | |
| 17 | + | ctrl := gomock.NewController(t) | |
| 18 | + | defer ctrl.Finish() | |
| 19 | + | ||
| 20 | + | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 21 | + | ctx := context.Background() | |
| 22 | + | ||
| 23 | + | // Mock test history data | |
| 24 | + | testHistory := []db.History{ | |
| 25 | + | { | |
| 26 | + | ID: 1, | |
| 27 | + | Url: "https://github.com", | |
| 28 | + | Title: sql.NullString{ | |
| 29 | + | String: "GitHub", | |
| 30 | + | Valid: true, | |
| 31 | + | }, | |
| 32 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | ID: 2, | |
| 36 | + | Url: "https://google.com", | |
| 37 | + | Title: sql.NullString{ | |
| 38 | + | String: "Google", | |
| 39 | + | Valid: true, | |
| 40 | + | }, | |
| 41 | + | VisitCount: sql.NullInt64{Int64: 10, Valid: true}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | ID: 3, | |
| 45 | + | Url: "https://stackoverflow.com", | |
| 46 | + | Title: sql.NullString{ | |
| 47 | + | String: "Stack Overflow", | |
| 48 | + | Valid: true, | |
| 49 | + | }, | |
| 50 | + | VisitCount: sql.NullInt64{Int64: 3, Valid: true}, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Expect GetHistory to be called when loading fuzzy cache | |
| 55 | + | mockDB.EXPECT(). | |
| 56 | + | GetHistory(gomock.Any(), gomock.Any()). | |
| 57 | + | Return(testHistory, nil). | |
| 58 | + | MinTimes(1) | |
| 59 | + | ||
| 60 | + | // Create browser service (fuzzy cache initialized) | |
| 61 | + | cfg := &config.Config{} | |
| 62 | + | service := NewBrowserService(cfg, mockDB) | |
| 63 | + | ||
| 64 | + | // Test 1: Verify fuzzy cache was initialized | |
| 65 | + | if service.fuzzyCache == nil { | |
| 66 | + | t.Fatal("Fuzzy cache should be initialized") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Test 2: Load fuzzy cache from DB | |
| 70 | + | err := service.LoadFuzzyCacheFromDB(ctx) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatalf("Failed to load fuzzy cache: %v", err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Test 3: Verify cache has entries | |
| 76 | + | stats := service.fuzzyCache.Stats() | |
| 77 | + | if stats.EntryCount == 0 { | |
| 78 | + | t.Error("Fuzzy cache should have entries after loading") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | t.Logf("Fuzzy cache loaded: %d entries, %d trigrams", stats.EntryCount, stats.TrigramCount) | |
| 82 | + | ||
| 83 | + | // Test 4: Verify refresh tracking is initialized | |
| 84 | + | if service.historySinceRefresh != 0 { | |
| 85 | + | t.Error("historySinceRefresh should start at 0") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | if service.lastCacheRefresh.IsZero() { | |
| 89 | + | t.Error("lastCacheRefresh should be initialized") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Test 5: Flush all caches (should include fuzzy cache) | |
| 93 | + | err = service.FlushAllCaches(ctx) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatalf("Failed to flush caches: %v", err) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | t.Log("Fuzzy cache integration test passed") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // TestBrowserService_FuzzyCacheSmartRefresh tests the smart refresh logic | |
| 102 | + | func TestBrowserService_FuzzyCacheSmartRefresh(t *testing.T) { | |
| 103 | + | ctrl := gomock.NewController(t) | |
| 104 | + | defer ctrl.Finish() | |
| 105 | + | ||
| 106 | + | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 107 | + | cfg := &config.Config{} | |
| 108 | + | ||
| 109 | + | // Expect GetHistory to be called when loading fuzzy cache | |
| 110 | + | mockDB.EXPECT(). | |
| 111 | + | GetHistory(gomock.Any(), gomock.Any()). | |
| 112 | + | Return([]db.History{}, nil). | |
| 113 | + | AnyTimes() | |
| 114 | + | ||
| 115 | + | service := NewBrowserService(cfg, mockDB) | |
| 116 | + | ||
| 117 | + | // Load initial cache | |
| 118 | + | ctx := context.Background() | |
| 119 | + | err := service.LoadFuzzyCacheFromDB(ctx) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatalf("Failed to load fuzzy cache: %v", err) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Test 1: Verify initial state | |
| 125 | + | if service.historySinceRefresh != 0 { | |
| 126 | + | t.Error("historySinceRefresh should start at 0") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | initialRefreshTime := service.lastCacheRefresh | |
| 130 | + | ||
| 131 | + | // Test 2: Simulate adding less than threshold entries (< 10) | |
| 132 | + | service.historySinceRefresh = 5 | |
| 133 | + | service.lastCacheRefresh = time.Now().Add(-10 * time.Minute) // Old enough | |
| 134 | + | ||
| 135 | + | // This should NOT trigger refresh (not enough entries) | |
| 136 | + | // Manually check the condition | |
| 137 | + | const minEntries = 10 | |
| 138 | + | const minInterval = 5 * time.Minute | |
| 139 | + | ||
| 140 | + | shouldRefresh := service.historySinceRefresh >= minEntries && | |
| 141 | + | time.Since(service.lastCacheRefresh) >= minInterval | |
| 142 | + | ||
| 143 | + | if shouldRefresh { | |
| 144 | + | t.Error("Should not trigger refresh with only 5 entries") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Test 3: Simulate enough entries but too recent | |
| 148 | + | service.historySinceRefresh = 15 | |
| 149 | + | service.lastCacheRefresh = time.Now() // Just refreshed | |
| 150 | + | ||
| 151 | + | shouldRefresh = service.historySinceRefresh >= minEntries && | |
| 152 | + | time.Since(service.lastCacheRefresh) >= minInterval | |
| 153 | + | ||
| 154 | + | if shouldRefresh { | |
| 155 | + | t.Error("Should not trigger refresh when last refresh was too recent") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // Test 4: Simulate conditions that SHOULD trigger refresh | |
| 159 | + | service.historySinceRefresh = 15 | |
| 160 | + | service.lastCacheRefresh = time.Now().Add(-10 * time.Minute) | |
| 161 | + | ||
| 162 | + | shouldRefresh = service.historySinceRefresh >= minEntries && | |
| 163 | + | time.Since(service.lastCacheRefresh) >= minInterval | |
| 164 | + | ||
| 165 | + | if !shouldRefresh { | |
| 166 | + | t.Error("Should trigger refresh with 15 entries and 10 minutes passed") | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | t.Logf("Smart refresh logic validated: %d entries, %v since refresh, should refresh: %v", | |
| 170 | + | service.historySinceRefresh, | |
| 171 | + | time.Since(service.lastCacheRefresh), | |
| 172 | + | shouldRefresh) | |
| 173 | + | ||
| 174 | + | // Test 5: Verify counter would reset after refresh | |
| 175 | + | if shouldRefresh { | |
| 176 | + | // Simulate the reset that happens in the actual code | |
| 177 | + | service.historySinceRefresh = 0 | |
| 178 | + | service.lastCacheRefresh = time.Now() | |
| 179 | + | ||
| 180 | + | if service.historySinceRefresh != 0 { | |
| 181 | + | t.Error("Counter should reset after refresh") | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Test 6: Verify old refresh time was updated | |
| 186 | + | if !service.lastCacheRefresh.After(initialRefreshTime) { | |
| 187 | + | t.Error("Refresh time should be updated") | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | t.Log("Smart refresh logic test passed") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // TestBrowserService_FuzzyCacheThresholds tests the threshold constants | |
| 194 | + | func TestBrowserService_FuzzyCacheThresholds(t *testing.T) { | |
| 195 | + | // This test documents and validates the threshold values | |
| 196 | + | ||
| 197 | + | const minEntries = 10 // From the actual code | |
| 198 | + | const minInterval = 5 * time.Minute // From the actual code | |
| 199 | + | ||
| 200 | + | t.Logf("Fuzzy cache refresh thresholds:") | |
| 201 | + | t.Logf(" - Minimum entries: %d", minEntries) | |
| 202 | + | t.Logf(" - Minimum interval: %v", minInterval) | |
| 203 | + | ||
| 204 | + | // Test reasonable threshold values | |
| 205 | + | if minEntries < 5 { | |
| 206 | + | t.Error("minEntries too low - will refresh too frequently") | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | if minEntries > 100 { | |
| 210 | + | t.Error("minEntries too high - cache will be stale") | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | if minInterval < 1*time.Minute { | |
| 214 | + | t.Error("minInterval too low - will refresh too frequently") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | if minInterval > 30*time.Minute { | |
| 218 | + | t.Error("minInterval too high - cache will be stale") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Test that both conditions are required | |
| 222 | + | // This prevents: | |
| 223 | + | // - Refreshing after 1 entry but 10 minutes (would be wasteful) | |
| 224 | + | // - Refreshing after 100 entries but 1 second (would be too frequent) | |
| 225 | + | ||
| 226 | + | t.Log("Threshold validation passed - both conditions required for refresh") | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // TestBrowserService_FuzzyCacheLoadFromDB tests loading cache at startup | |
| 230 | + | func TestBrowserService_FuzzyCacheLoadFromDB(t *testing.T) { | |
| 231 | + | ctrl := gomock.NewController(t) | |
| 232 | + | defer ctrl.Finish() | |
| 233 | + | ||
| 234 | + | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |
| 235 | + | ctx := context.Background() | |
| 236 | + | ||
| 237 | + | // Mock test history entries | |
| 238 | + | testHistory := []db.History{ | |
| 239 | + | { | |
| 240 | + | ID: 1, | |
| 241 | + | Url: "https://github.com/user/repo", | |
| 242 | + | Title: sql.NullString{String: "GitHub Repository", Valid: true}, | |
| 243 | + | VisitCount: sql.NullInt64{Int64: 10, Valid: true}, | |
| 244 | + | }, | |
| 245 | + | { | |
| 246 | + | ID: 2, | |
| 247 | + | Url: "https://stackoverflow.com/questions/123", | |
| 248 | + | Title: sql.NullString{String: "Stack Overflow Question", Valid: true}, | |
| 249 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |
| 250 | + | }, | |
| 251 | + | { | |
| 252 | + | ID: 3, | |
| 253 | + | Url: "https://golang.org/doc", | |
| 254 | + | Title: sql.NullString{String: "Go Documentation", Valid: true}, | |
| 255 | + | VisitCount: sql.NullInt64{Int64: 15, Valid: true}, | |
| 256 | + | }, | |
| 257 | + | { | |
| 258 | + | ID: 4, | |
| 259 | + | Url: "https://reddit.com/r/golang", | |
| 260 | + | Title: sql.NullString{String: "Golang Subreddit", Valid: true}, | |
| 261 | + | VisitCount: sql.NullInt64{Int64: 3, Valid: true}, | |
| 262 | + | }, | |
| 263 | + | { | |
| 264 | + | ID: 5, | |
| 265 | + | Url: "https://news.ycombinator.com", | |
| 266 | + | Title: sql.NullString{String: "Hacker News", Valid: true}, | |
| 267 | + | VisitCount: sql.NullInt64{Int64: 20, Valid: true}, | |
| 268 | + | }, | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // Expect GetHistory to be called when loading fuzzy cache | |
| 272 | + | mockDB.EXPECT(). | |
| 273 | + | GetHistory(gomock.Any(), gomock.Any()). | |
| 274 | + | Return(testHistory, nil). | |
| 275 | + | MinTimes(1) | |
| 276 | + | ||
| 277 | + | // Create service and load cache | |
| 278 | + | cfg := &config.Config{} | |
| 279 | + | service := NewBrowserService(cfg, mockDB) | |
| 280 | + | ||
| 281 | + | // Load cache from DB | |
| 282 | + | err := service.LoadFuzzyCacheFromDB(ctx) | |
| 283 | + | if err != nil { | |
| 284 | + | t.Fatalf("Failed to load fuzzy cache from DB: %v", err) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // Verify cache has loaded entries | |
| 288 | + | stats := service.fuzzyCache.Stats() | |
| 289 | + | if stats.EntryCount < len(testHistory) { | |
| 290 | + | t.Errorf("Expected at least %d entries, got %d", len(testHistory), stats.EntryCount) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | if stats.TrigramCount == 0 { | |
| 294 | + | t.Error("Expected trigram index to be built") | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | t.Logf("Loaded fuzzy cache: %d entries, %d trigrams", stats.EntryCount, stats.TrigramCount) | |
| 298 | + | ||
| 299 | + | // Test search functionality | |
| 300 | + | result, err := service.fuzzyCache.Search(ctx, "github") | |
| 301 | + | if err != nil { | |
| 302 | + | t.Fatalf("Failed to search fuzzy cache: %v", err) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | if len(result.Matches) == 0 { | |
| 306 | + | t.Error("Expected to find matches for 'github'") | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | t.Logf("Search for 'github' found %d matches", len(result.Matches)) | |
| 310 | + | ||
| 311 | + | t.Log("Load from DB test passed") | |
| 312 | + | } | |
M internal/services/favicon_service.go +6 -3
| @@ -4,6 +4,7 @@ package services | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "database/sql" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "log" | |
| 9 | 10 | "os" | |
| @@ -52,7 +53,7 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue | |||
| 52 | 53 | ||
| 53 | 54 | // Create export directory for CLI access | |
| 54 | 55 | exportDir := filepath.Join(dataDir, "favicons-export") | |
| 55 | - | if err := os.MkdirAll(exportDir, 0755); err != nil { | |
| 56 | + | if err := os.MkdirAll(exportDir, 0750); err != nil { | |
| 56 | 57 | return nil, fmt.Errorf("failed to create favicon export directory: %w", err) | |
| 57 | 58 | } | |
| 58 | 59 | ||
| @@ -291,7 +292,7 @@ func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string) | |||
| 291 | 292 | if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" { | |
| 292 | 293 | return entry.FaviconUrl.String, nil | |
| 293 | 294 | } | |
| 294 | - | } else if err != sql.ErrNoRows { | |
| 295 | + | } else if !errors.Is(err, sql.ErrNoRows) { | |
| 295 | 296 | log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err) | |
| 296 | 297 | } | |
| 297 | 298 | ||
| @@ -439,7 +440,9 @@ func (fs *FaviconService) CleanOldExports() error { | |||
| 439 | 440 | } | |
| 440 | 441 | ||
| 441 | 442 | if info.ModTime().Before(cutoff) { | |
| 442 | - | os.Remove(filepath.Join(fs.exportDir, entry.Name())) | |
| 443 | + | if err := os.Remove(filepath.Join(fs.exportDir, entry.Name())); err != nil { | |
| 444 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove old favicon: %v\n", err) | |
| 445 | + | } | |
| 443 | 446 | } | |
| 444 | 447 | } | |
| 445 | 448 | ||
M pkg/webkit/keyboard_bridge.go +8 -6
| @@ -11,9 +11,9 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | // Keyboard shortcut modifier string constants | |
| 13 | 13 | const ( | |
| 14 | - | modifierCmdOrCtrl = "cmdorctrl" | |
| 15 | - | modifierShift = "shift" | |
| 16 | - | modifierAlt = "alt" | |
| 14 | + | modifierCtrl = "ctrl" | |
| 15 | + | modifierShift = "shift" | |
| 16 | + | modifierAlt = "alt" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // GDK key constants for special keys | |
| @@ -94,6 +94,8 @@ var shortcutActions = map[string]string{ | |||
| 94 | 94 | altmod + "+" + keyArrowUp: directionUp, | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30) | |
| 98 | + | // | |
| 97 | 99 | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | |
| 98 | 100 | // This is critical for allowing JavaScript KeyboardService to receive keyboard events | |
| 99 | 101 | // that would otherwise be consumed by GTK shortcuts | |
| @@ -127,9 +129,9 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 127 | 129 | // Build modifier prefix using switch on modifier combination | |
| 128 | 130 | switch { | |
| 129 | 131 | case ctrl && shift: | |
| 130 | - | parts = append(parts, modifierCmdOrCtrl, modifierShift) | |
| 132 | + | parts = append(parts, modifierCtrl, modifierShift) | |
| 131 | 133 | case ctrl && !shift: | |
| 132 | - | parts = append(parts, modifierCmdOrCtrl) | |
| 134 | + | parts = append(parts, modifierCtrl) | |
| 133 | 135 | case alt: | |
| 134 | 136 | parts = append(parts, modifierAlt) | |
| 135 | 137 | case shift: | |
| @@ -199,7 +201,7 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 199 | 201 | ||
| 200 | 202 | if shortcut != "" { | |
| 201 | 203 | // Special case: Ctrl+P enters pane mode | |
| 202 | - | if shortcut == modifierCmdOrCtrl+"+"+keyP { | |
| 204 | + | if shortcut == modifierCtrl+"+"+keyP { | |
| 203 | 205 | w.mu.RLock() | |
| 204 | 206 | handler := w.onPaneModeShortcut | |
| 205 | 207 | w.mu.RUnlock() | |
M pkg/webkit/mainloop.go +0 -1
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | var ( | |
| 11 | 11 | mainLoop *glib.MainLoop | |
| 12 | - | mainThreadID = runtime.LockOSThread // Store the main thread | |
| 13 | 12 | isInitialized bool | |
| 14 | 13 | ) | |
| 15 | 14 | ||
M pkg/webkit/network_session.go +13 -0
| @@ -102,3 +102,16 @@ func InitPersistentSession(dataDir, cacheDir string) error { | |||
| 102 | 102 | func GetGlobalNetworkSession() *webkit.NetworkSession { | |
| 103 | 103 | return globalNetworkSession | |
| 104 | 104 | } | |
| 105 | + | ||
| 106 | + | // PrefetchDNS prefetches DNS for the given hostname to speed up future requests. | |
| 107 | + | // This is useful for preloading DNS for links that the user might click. | |
| 108 | + | // Safe to call even if the hostname is invalid or session is not initialized. | |
| 109 | + | func PrefetchDNS(hostname string) { | |
| 110 | + | if globalNetworkSession == nil { | |
| 111 | + | return // Session not initialized yet | |
| 112 | + | } | |
| 113 | + | if hostname == "" { | |
| 114 | + | return // Nothing to prefetch | |
| 115 | + | } | |
| 116 | + | globalNetworkSession.PrefetchDns(hostname) | |
| 117 | + | } | |
M pkg/webkit/tls_handler.go +17 -36
| @@ -2,8 +2,8 @@ package webkit | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "crypto/sha256" | |
| 6 | 5 | "database/sql" | |
| 6 | + | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "log" | |
| 9 | 9 | "net/url" | |
| @@ -83,37 +83,6 @@ func extractHostname(uri string) string { | |||
| 83 | 83 | return host | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | - | // formatCertificateInfo formats certificate information for display and hashing | |
| 87 | - | func formatCertificateInfo(certificate gio.TLSCertificater) string { | |
| 88 | - | // Convert to TLSCertificate to access methods | |
| 89 | - | tlsCert := gio.BaseTLSCertificate(certificate) | |
| 90 | - | if tlsCert == nil { | |
| 91 | - | // Fallback: use the object pointer address as a weak identifier | |
| 92 | - | return fmt.Sprintf("%p", certificate) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Build a unique string from certificate properties | |
| 96 | - | // Use subject, issuer, and validity period | |
| 97 | - | subject := tlsCert.SubjectName() | |
| 98 | - | issuer := "" | |
| 99 | - | if issuerCert := tlsCert.Issuer(); issuerCert != nil { | |
| 100 | - | issuer = gio.BaseTLSCertificate(issuerCert).SubjectName() | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | notBefore := tlsCert.NotValidBefore() | |
| 104 | - | notAfter := tlsCert.NotValidAfter() | |
| 105 | - | ||
| 106 | - | // Combine properties into a unique string | |
| 107 | - | return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v", | |
| 108 | - | subject, issuer, notBefore, notAfter) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // getCertificateHash generates a SHA256 hash of the certificate info | |
| 112 | - | func getCertificateHash(certificateInfo string) string { | |
| 113 | - | hash := sha256.Sum256([]byte(certificateInfo)) | |
| 114 | - | return fmt.Sprintf("%x", hash) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | 86 | // checkStoredCertificateDecision checks if user has previously decided on this hostname | |
| 118 | 87 | func checkStoredCertificateDecision(hostname string) (string, bool) { | |
| 119 | 88 | cfg := config.Get() | |
| @@ -133,7 +102,11 @@ func checkStoredCertificateDecision(hostname string) (string, bool) { | |||
| 133 | 102 | log.Printf("[tls] Failed to open database: %v", err) | |
| 134 | 103 | return "", false | |
| 135 | 104 | } | |
| 136 | - | defer database.Close() | |
| 105 | + | defer func() { | |
| 106 | + | if err := database.Close(); err != nil { | |
| 107 | + | log.Printf("[tls] warning: failed to close database: %v", err) | |
| 108 | + | } | |
| 109 | + | }() | |
| 137 | 110 | ||
| 138 | 111 | queries := db.New(database) | |
| 139 | 112 | ctx := context.Background() | |
| @@ -141,7 +114,7 @@ func checkStoredCertificateDecision(hostname string) (string, bool) { | |||
| 141 | 114 | // Get any validation for this hostname (we don't verify cert hash anymore) | |
| 142 | 115 | validation, err := queries.GetCertificateValidationByHostname(ctx, hostname) | |
| 143 | 116 | if err != nil { | |
| 144 | - | if err == sql.ErrNoRows { | |
| 117 | + | if errors.Is(err, sql.ErrNoRows) { | |
| 145 | 118 | return "", false | |
| 146 | 119 | } | |
| 147 | 120 | log.Printf("[tls] Error checking certificate validation: %v", err) | |
| @@ -167,7 +140,11 @@ func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) | |||
| 167 | 140 | if err != nil { | |
| 168 | 141 | return fmt.Errorf("failed to open database: %w", err) | |
| 169 | 142 | } | |
| 170 | - | defer database.Close() | |
| 143 | + | defer func() { | |
| 144 | + | if err := database.Close(); err != nil { | |
| 145 | + | log.Printf("[tls] warning: failed to close database: %v", err) | |
| 146 | + | } | |
| 147 | + | }() | |
| 171 | 148 | ||
| 172 | 149 | queries := db.New(database) | |
| 173 | 150 | ctx := context.Background() | |
| @@ -338,7 +315,11 @@ func CleanupExpiredCertificateValidations() error { | |||
| 338 | 315 | if err != nil { | |
| 339 | 316 | return fmt.Errorf("failed to open database: %w", err) | |
| 340 | 317 | } | |
| 341 | - | defer database.Close() | |
| 318 | + | defer func() { | |
| 319 | + | if err := database.Close(); err != nil { | |
| 320 | + | log.Printf("[tls] warning: failed to close database: %v", err) | |
| 321 | + | } | |
| 322 | + | }() | |
| 342 | 323 | ||
| 343 | 324 | queries := db.New(database) | |
| 344 | 325 | ctx := context.Background() | |
M pkg/webkit/types.go +17 -8
| @@ -45,6 +45,13 @@ type Config struct { | |||
| 45 | 45 | // MinimumFontSize in pixels | |
| 46 | 46 | MinimumFontSize int | |
| 47 | 47 | ||
| 48 | + | // Performance optimizations | |
| 49 | + | // EnablePageCache enables back/forward cache for instant navigation | |
| 50 | + | EnablePageCache bool | |
| 51 | + | ||
| 52 | + | // EnableSmoothScrolling enables smooth scrolling animations | |
| 53 | + | EnableSmoothScrolling bool | |
| 54 | + | ||
| 48 | 55 | // DataDir is the directory for persistent data (cookies, localStorage, etc.) | |
| 49 | 56 | DataDir string | |
| 50 | 57 | ||
| @@ -63,13 +70,15 @@ type Config struct { | |||
| 63 | 70 | // GetDefaultConfig returns a Config with sensible defaults | |
| 64 | 71 | func GetDefaultConfig() *Config { | |
| 65 | 72 | return &Config{ | |
| 66 | - | UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", | |
| 67 | - | EnableJavaScript: true, | |
| 68 | - | EnableWebGL: true, | |
| 69 | - | EnableMediaStream: true, | |
| 70 | - | HardwareAcceleration: true, | |
| 71 | - | DefaultFontSize: 16, | |
| 72 | - | MinimumFontSize: 8, | |
| 73 | - | CreateWindow: true, // Default to standalone window | |
| 73 | + | UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", | |
| 74 | + | EnableJavaScript: true, | |
| 75 | + | EnableWebGL: true, | |
| 76 | + | EnableMediaStream: true, | |
| 77 | + | HardwareAcceleration: true, | |
| 78 | + | DefaultFontSize: 16, | |
| 79 | + | MinimumFontSize: 8, | |
| 80 | + | EnablePageCache: true, // Instant back/forward navigation | |
| 81 | + | EnableSmoothScrolling: true, // Better UX | |
| 82 | + | CreateWindow: true, // Default to standalone window | |
| 74 | 83 | } | |
| 75 | 84 | } | |
M pkg/webkit/webview.go +19 -10
| @@ -197,6 +197,13 @@ func (w *WebView) applyConfig() error { | |||
| 197 | 197 | // Enable hardware acceleration if configured | |
| 198 | 198 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways) | |
| 199 | 199 | ||
| 200 | + | // Performance optimizations for faster page transitions | |
| 201 | + | // Enable page cache for instant back/forward navigation (bfcache) | |
| 202 | + | settings.SetEnablePageCache(w.config.EnablePageCache) | |
| 203 | + | ||
| 204 | + | // Enable smooth scrolling for better UX | |
| 205 | + | settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling) | |
| 206 | + | ||
| 200 | 207 | return nil | |
| 201 | 208 | } | |
| 202 | 209 | ||
| @@ -229,11 +236,6 @@ func (w *WebView) setupEventHandlers() { | |||
| 229 | 236 | // Favicon changed - connect to FaviconDatabase | |
| 230 | 237 | w.setupFaviconHandlers() | |
| 231 | 238 | ||
| 232 | - | // Load changed | |
| 233 | - | w.view.ConnectLoadChanged(func(event webkit.LoadEvent) { | |
| 234 | - | // Handle load events if needed | |
| 235 | - | }) | |
| 236 | - | ||
| 237 | 239 | // TLS error handling - connect to load-failed-with-tls-errors signal | |
| 238 | 240 | w.setupTLSErrorHandler() | |
| 239 | 241 | ||
| @@ -418,8 +420,12 @@ func (w *WebView) Hide() error { | |||
| 418 | 420 | ||
| 419 | 421 | // Destroy destroys the WebView and releases resources | |
| 420 | 422 | func (w *WebView) Destroy() error { | |
| 421 | - | w.mu.Lock() | |
| 422 | - | defer w.mu.Unlock() | |
| 423 | + | // DO NOT acquire w.mu lock here to avoid deadlock! | |
| 424 | + | // After GTK main loop exits, any goroutine holding the lock and calling GTK methods | |
| 425 | + | // will block forever waiting for the main loop. This causes Destroy() to deadlock | |
| 426 | + | // trying to acquire the same lock. | |
| 427 | + | // | |
| 428 | + | // During shutdown, thread safety is not critical since we're tearing everything down. | |
| 423 | 429 | ||
| 424 | 430 | if w.destroyed { | |
| 425 | 431 | return nil | |
| @@ -432,14 +438,17 @@ func (w *WebView) Destroy() error { | |||
| 432 | 438 | delete(viewRegistry, w.id) | |
| 433 | 439 | viewMu.Unlock() | |
| 434 | 440 | ||
| 435 | - | // The GTK widget will be cleaned up by Go GC | |
| 441 | + | // DO NOT call StopLoading() or any other GTK methods here! | |
| 442 | + | // After the main loop exits, GTK calls will block forever. | |
| 443 | + | // The GTK widget will be cleaned up by Go GC. | |
| 444 | + | ||
| 436 | 445 | return nil | |
| 437 | 446 | } | |
| 438 | 447 | ||
| 439 | 448 | // IsDestroyed returns true if the WebView has been destroyed | |
| 440 | 449 | func (w *WebView) IsDestroyed() bool { | |
| 441 | - | w.mu.RLock() | |
| 442 | - | defer w.mu.RUnlock() | |
| 450 | + | // DO NOT acquire lock - read of bool is atomic on all platforms Go supports | |
| 451 | + | // This prevents deadlock during shutdown when GTK signal handlers check IsDestroyed | |
| 443 | 452 | return w.destroyed | |
| 444 | 453 | } | |
| 445 | 454 | ||
M tests/integration/browser_controls_integration_test.go +8 -1
| @@ -93,10 +93,17 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) { | |||
| 93 | 93 | func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | |
| 94 | 94 | _, queries, cleanup := setupTestDB(t) | |
| 95 | 95 | ||
| 96 | - | cfg := &config.Config{} | |
| 96 | + | cfg := &config.Config{ | |
| 97 | + | DefaultZoom: 1.0, // Set default zoom level | |
| 98 | + | } | |
| 97 | 99 | ||
| 98 | 100 | service := services.NewBrowserService(cfg, queries) | |
| 99 | 101 | ||
| 102 | + | // Load zoom cache (simulates startup behavior) | |
| 103 | + | if err := service.LoadZoomCacheFromDB(context.Background()); err != nil { | |
| 104 | + | t.Fatalf("Failed to load zoom cache: %v", err) | |
| 105 | + | } | |
| 106 | + | ||
| 100 | 107 | return service, cleanup | |
| 101 | 108 | } | |
| 102 | 109 | ||