detached 9dd8040

Merge pull request #7 from bnema/dev

feat: keyboard shortcuts, cache improvements, and error handling
commit
9dd804079fe9a721b4785226fbadd9a526dbe7be
author
bnema · · 2025-10-24 17:53 UTC
committer
GitHub · · 2025-10-24 17:53 UTC
merge of
24d05c8 + 8008fca · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
76 files · +3107 -794
M .gitignore +1 -0
@@ -82,3 +82,4 @@ templates/
8282 .serena/
8383 config.yaml
8484 .dev/
85+vendor/
M CHANGELOG.md +20 -0
@@ -6,6 +6,26 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88 - **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
929
1030 ## [0.12.1] - 2025-10-18
1131
M README.md +32 -128
@@ -41,22 +41,22 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
4141 | Shortcut | Action | Notes |
4242 |----------|--------|-------|
4343 | **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 |
5454 | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation |
5555
5656 #### Zellij-Inspired Pane Management
5757 | Shortcut | Action | Notes |
5858 |----------|--------|-------|
59-| **Ctrl/Cmd+P** | Enter Pane Mode | Modal mode for pane operations |
59+| **Ctrl+P** | Enter Pane Mode | Modal mode for pane operations |
6060 | **→** / **R** (in pane mode) | Split Right | Create new pane to the right |
6161 | **←** / **L** (in pane mode) | Split Left | Create new pane to the left |
6262 | **↑** / **U** (in pane mode) | Split Up | Create new pane above |
@@ -178,7 +178,7 @@ The pane system is fully configurable via `config.json`:
178178 "workspace": {
179179 "enable_zellij_controls": true,
180180 "pane_mode": {
181- "activation_shortcut": "cmdorctrl+p",
181+ "activation_shortcut": "ctrl+p",
182182 "timeout_ms": 3000,
183183 "action_bindings": {
184184 "arrowright": "split-right",
@@ -208,134 +208,38 @@ Dumber follows the XDG Base Directory spec:
208208
209209 A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible.
210210
211-### Complete Configuration Reference
211+### How Configuration Works
212212
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
222219
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
231221
232-#### Search Shortcuts
233222 ```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+{
283224 "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" }
301232 }
302233 }
303234 ```
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:
316235
236+Environment variable override:
317237 ```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
332240 ```
333241
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.
339243
340244
341245 ## 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 (
77 github.com/diamondburned/gotk4/pkg v0.3.1
88 github.com/fsnotify/fsnotify v1.9.0
99 github.com/golang/mock v1.6.0
10+ github.com/invopop/jsonschema v0.13.0
1011 github.com/ncruces/go-sqlite3 v0.29.0
1112 github.com/spf13/cobra v1.10.1
1213 github.com/spf13/viper v1.21.0
14+ go.uber.org/mock v0.6.0
15+ golang.org/x/sync v0.17.0
1316 golang.org/x/sys v0.36.0
1417 )
1518
1619 require (
1720 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
1823 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
1924 github.com/google/go-cmp v0.7.0 // indirect
2025 github.com/inconshreveable/mousetrap v1.1.0 // indirect
26+ github.com/mailru/easyjson v0.7.7 // indirect
2127 github.com/ncruces/julianday v1.0.0 // indirect
2228 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
2329 github.com/rogpeppe/go-internal v1.12.0 // indirect
@@ -28,11 +34,12 @@ require (
2834 github.com/spf13/pflag v1.0.10 // indirect
2935 github.com/subosito/gotenv v1.6.0 // indirect
3036 github.com/tetratelabs/wazero v1.9.0 // indirect
37+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
3138 go.yaml.in/yaml/v3 v3.0.4 // indirect
3239 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
33- golang.org/x/sync v0.17.0 // indirect
3440 golang.org/x/text v0.29.0 // indirect
3541 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
42+ gopkg.in/yaml.v3 v3.0.1 // indirect
3643 )
3744
3845 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 {
343343 const parts: string[] = [];
344344
345345 // 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) {
350347 parts.push("ctrl");
351- } else if (event.metaKey) {
352- parts.push("cmd");
353348 }
354349
355350 if (event.altKey) parts.push("alt");
@@ -407,20 +402,20 @@ export const keyboardService = new KeyboardService({
407402 globalShortcuts: [
408403 // Navigation shortcuts (migrated from controls module)
409404 {
410- key: "cmdorctrl+arrowleft",
405+ key: "ctrl+arrowleft",
411406 handler: () => {
412407 if (window.history.length > 1) {
413408 window.history.back();
414409 }
415410 },
416- description: "Navigate back (Ctrl/Cmd+Left Arrow)",
411+ description: "Navigate back (Ctrl+Left Arrow)",
417412 },
418413 {
419- key: "cmdorctrl+arrowright",
414+ key: "ctrl+arrowright",
420415 handler: () => {
421416 window.history.forward();
422417 },
423- description: "Navigate forward (Ctrl/Cmd+Right Arrow)",
418+ description: "Navigate forward (Ctrl+Right Arrow)",
424419 },
425420 // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
426421 // 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 @@
66 */
77
88 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') */
1010 key: string;
1111
1212 /** Function to execute when the shortcut is triggered */
@@ -78,8 +78,6 @@ export interface KeyboardEvent {
7878 */
7979 export const MODIFIER_KEYS = {
8080 ctrl: "ctrlKey",
81- cmd: "metaKey",
82- cmdorctrl: navigator.platform.includes("Mac") ? "metaKey" : "ctrlKey",
8381 alt: "altKey",
8482 shift: "shiftKey",
8583 } as const;
@@ -88,8 +86,8 @@ export const MODIFIER_KEYS = {
8886 * Common shortcut patterns used throughout the application
8987 */
9088 export const COMMON_SHORTCUTS = {
91- OMNIBOX_OPEN: "cmdorctrl+l",
92- FIND_OPEN: "cmdorctrl+f",
89+ OMNIBOX_OPEN: "ctrl+l",
90+ FIND_OPEN: "ctrl+f",
9391 ESCAPE: "escape",
9492 ENTER: "enter",
9593 ARROW_UP: "arrowup",
M internal/app/browser/browser.go +105 -2
@@ -1,13 +1,16 @@
11 package browser
22
33 import (
4+ "context"
45 "database/sql"
56 "embed"
7+ "fmt"
68 "log"
79 "os"
810 "os/signal"
911 "runtime"
1012 "syscall"
13+ "time"
1114
1215 "github.com/bnema/dumber/internal/app/api"
1316 "github.com/bnema/dumber/internal/app/control"
@@ -19,6 +22,7 @@ import (
1922 "github.com/bnema/dumber/internal/logging"
2023 "github.com/bnema/dumber/internal/services"
2124 "github.com/bnema/dumber/pkg/webkit"
25+ "golang.org/x/sync/errgroup"
2226 )
2327
2428 // BrowserApp represents the main browser application
@@ -61,6 +65,7 @@ type BrowserApp struct {
6165
6266 // Run starts the browser application
6367 func Run(assets embed.FS, version, commit, buildDate string) {
68+ startupStart := time.Now()
6469 log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
6570
6671 app := &BrowserApp{
@@ -78,6 +83,12 @@ func Run(assets embed.FS, version, commit, buildDate string) {
7883 os.Exit(1)
7984 }
8085
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+
8192 app.Run()
8293 }
8394
@@ -136,6 +147,12 @@ func (app *BrowserApp) Initialize() error {
136147 app.parserService = services.NewParserService(app.config, app.queries)
137148 app.browserService = services.NewBrowserService(app.config, app.queries)
138149
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+
139156 // Initialize handlers
140157 app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
141158 app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
@@ -143,6 +160,57 @@ func (app *BrowserApp) Initialize() error {
143160 return nil
144161 }
145162
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+
146214 // Run executes the main browser loop
147215 func (app *BrowserApp) Run() {
148216 defer app.cleanup()
@@ -211,11 +279,32 @@ func (app *BrowserApp) cleanup() {
211279 app.workspace = nil
212280 }
213281
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
215293 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
217304 if closeErr := app.database.Close(); closeErr != nil {
218305 log.Printf("Warning: failed to close database: %v", closeErr)
306+ } else {
307+ log.Printf("Database closed successfully")
219308 }
220309 }
221310
@@ -263,6 +352,20 @@ func (app *BrowserApp) runMainLoop() {
263352 log.Printf("Entering GTK main loop…")
264353 webkit.RunMainLoop()
265354 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+ }
266369 } else {
267370 log.Printf("Not entering GUI loop (non-CGO build)")
268371 // 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() {
104104 p.messageHandler.SetWebView(nil)
105105 }
106106
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-
124107 // Destroy WebView to unregister from global registry and release resources
125108 if p.webView != nil && !p.webView.IsDestroyed() {
126109 if err := p.webView.Destroy(); err != nil {
M internal/app/browser/shortcuts.go +2 -2
@@ -67,7 +67,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
6767 // by WindowShortcutHandler at the window level to prevent duplicates
6868
6969 // Page refresh shortcuts
70- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", 0, func() {
70+ _ = s.webView.RegisterKeyboardShortcut("ctrl+r", 0, func() {
7171 if !s.isActivePane() {
7272 return
7373 }
@@ -75,7 +75,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
7575 _ = s.webView.Reload()
7676 })
7777
78- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", 0, func() {
78+ _ = s.webView.RegisterKeyboardShortcut("ctrl+shift+r", 0, func() {
7979 if !s.isActivePane() {
8080 return
8181 }
M internal/app/browser/stacked_panes.go +7 -5
@@ -430,7 +430,7 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
430430 if pane.pane != nil && pane.pane.webView != nil {
431431 title := pane.pane.webView.GetTitle()
432432 if title == "" {
433- title = "New Tab"
433+ title = NewTabTitle
434434 }
435435 spm.updateTitleBarLabel(pane, title)
436436 }
@@ -475,12 +475,12 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
475475 // Calculate new index based on direction
476476 var newIndex int
477477 switch direction {
478- case "up":
478+ case DirectionUp:
479479 newIndex = currentIndex - 1
480480 if newIndex < 0 {
481481 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
482482 }
483- case "down":
483+ case DirectionDown:
484484 newIndex = currentIndex + 1
485485 if newIndex >= len(stackNode.stackedPanes) {
486486 newIndex = 0 // Wrap to first
@@ -524,11 +524,11 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
524524 if pane.pane != nil && pane.pane.webView != nil {
525525 titleText = pane.pane.webView.GetTitle()
526526 if titleText == "" {
527- titleText = "New Tab" // Fallback only when title is actually empty
527+ titleText = NewTabTitle // Fallback only when title is actually empty
528528 }
529529 pageURL = pane.pane.webView.GetCurrentURL()
530530 } else {
531- titleText = "New Tab"
531+ titleText = NewTabTitle
532532 }
533533
534534 titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
@@ -714,6 +714,8 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
714714 return titleBox
715715 }
716716
717+// TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30)
718+//
717719 // CloseStackedPane handles closing a pane that is part of a stack.
718720 // The logic mirrors the regular close path: we rebuild the tree, update focus,
719721 // 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) {
4343
4444 // Build WebKit configuration with data directories for persistence
4545 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
5759 }
5860
5961 return cfg, nil
M internal/app/browser/window_shortcuts.go +4 -4
@@ -69,10 +69,10 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
6969 {"ctrl+minus", h.handleZoomOut, "Zoom out"},
7070 {"ctrl+0", h.handleZoomReset, "Zoom reset"},
7171 // 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"},
7676 }
7777
7878 for _, shortcut := range shortcuts {
M internal/app/browser/workspace_css.go +3 -1
@@ -185,7 +185,9 @@ func (wm *WorkspaceManager) ensureWorkspaceStyles() {
185185
186186 // Only apply CSS if it hasn't been applied globally or if content changed
187187 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+ }
189191 globalCSSInitialized = true
190192 globalCSSContent = workspaceCSS
191193 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
166166 }
167167
168168 switch direction {
169- case "left", "right":
169+ case DirectionLeft, DirectionRight:
170170 return gv.ValidateHorizontalSplit(node)
171- case "up", "down":
171+ case DirectionUp, DirectionDown:
172172 return gv.ValidateVerticalSplit(node)
173173 default:
174174 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
259259 wm.SetActivePane(node, SourceProgrammatic)
260260 direction := strings.ToLower(msg.Direction)
261261 if direction == "" {
262- direction = "right"
262+ direction = DirectionRight
263263 }
264264
265265 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) {
105105 }
106106
107107 // 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
109109 // navigate within the stack.
110110 func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
111111 if wm == nil {
112112 return false
113113 }
114114 switch strings.ToLower(direction) {
115- case "up", "down":
115+ case DirectionUp, DirectionDown:
116116 // Check if current pane is part of a stack and handle stack navigation
117117 if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
118118 return true
119119 }
120120 // Fall back to regular adjacency navigation
121121 return wm.focusAdjacent(strings.ToLower(direction))
122- case "left", "right":
122+ case DirectionLeft, DirectionRight:
123123 return wm.focusAdjacent(strings.ToLower(direction))
124124 default:
125125 log.Printf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction)
@@ -169,12 +169,12 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
169169 // Calculate new index based on direction
170170 var newIndex int
171171 switch direction {
172- case "up":
172+ case DirectionUp:
173173 newIndex = currentIndex - 1
174174 if newIndex < 0 {
175175 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
176176 }
177- case "down":
177+ case DirectionDown:
178178 newIndex = currentIndex + 1
179179 if newIndex >= len(stackNode.stackedPanes) {
180180 newIndex = 0 // Wrap to first
@@ -235,29 +235,29 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
235235
236236 var score float64
237237 switch direction {
238- case "left":
238+ case DirectionLeft:
239239 if dx >= -focusEpsilon {
240240 continue
241241 }
242242 score = math.Abs(dx)*1000 + math.Abs(dy)
243- case "right":
243+ case DirectionRight:
244244 if dx <= focusEpsilon {
245245 continue
246246 }
247247 score = math.Abs(dx)*1000 + math.Abs(dy)
248- case "up":
248+ case DirectionUp:
249249 if dy >= -focusEpsilon {
250250 continue
251251 }
252252 score = math.Abs(dy)*1000 + math.Abs(dx)
253- case "down":
253+ case DirectionDown:
254254 if dy <= focusEpsilon {
255255 continue
256256 }
257257 score = math.Abs(dy)*1000 + math.Abs(dx)
258258 }
259259
260- if direction == "up" || direction == "down" {
260+ if direction == DirectionUp || direction == DirectionDown {
261261 debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
262262 }
263263
@@ -287,29 +287,29 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string)
287287 refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)
288288 cx := float64(refX) + float64(refWidth)/2.0
289289 cy := float64(refY) + float64(refHeight)/2.0
290- axisVertical := direction == "up" || direction == "down"
290+ axisVertical := direction == DirectionUp || direction == DirectionDown
291291
292292 for parent := node.parent; parent != nil; parent = parent.parent {
293293 switch direction {
294- case "up":
294+ case DirectionUp:
295295 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
296296 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
297297 return leaf
298298 }
299299 }
300- case "down":
300+ case DirectionDown:
301301 if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
302302 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
303303 return leaf
304304 }
305305 }
306- case "left":
306+ case DirectionLeft:
307307 if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node {
308308 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
309309 return leaf
310310 }
311311 }
312- case "right":
312+ case DirectionRight:
313313 if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node {
314314 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
315315 return leaf
@@ -338,22 +338,22 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
338338 dy := ty - cy
339339 var score float64
340340 switch direction {
341- case "left":
341+ case DirectionLeft:
342342 if dx >= -focusEpsilon {
343343 continue
344344 }
345345 score = math.Abs(dx)*1000 + math.Abs(dy)
346- case "right":
346+ case DirectionRight:
347347 if dx <= focusEpsilon {
348348 continue
349349 }
350350 score = math.Abs(dx)*1000 + math.Abs(dy)
351- case "up":
351+ case DirectionUp:
352352 if dy >= -focusEpsilon {
353353 continue
354354 }
355355 score = math.Abs(dy)*1000 + math.Abs(dx)
356- case "down":
356+ case DirectionDown:
357357 if dy <= focusEpsilon {
358358 continue
359359 }
@@ -393,22 +393,22 @@ func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction
393393 return node
394394 }
395395 switch direction {
396- case "up":
396+ case DirectionUp:
397397 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
398398 return leaf
399399 }
400400 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
401- case "down":
401+ case DirectionDown:
402402 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
403403 return leaf
404404 }
405405 return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
406- case "left":
406+ case DirectionLeft:
407407 if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
408408 return leaf
409409 }
410410 return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
411- case "right":
411+ case DirectionRight:
412412 if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
413413 return leaf
414414 }
M internal/app/browser/workspace_navigation_test.go +8 -8
@@ -77,10 +77,10 @@ func TestMapDirection(t *testing.T) {
7777 orientation gtk.Orientation
7878 existingFirst bool
7979 }{
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},
8484 }
8585
8686 for _, tt := range tests {
@@ -131,10 +131,10 @@ func TestCaseSensitiveDirections(t *testing.T) {
131131 "Down",
132132 "LEFT",
133133 "Right",
134- "up",
135- "down",
136- "left",
137- "right",
134+ DirectionUp,
135+ DirectionDown,
136+ DirectionLeft,
137+ DirectionRight,
138138 }
139139
140140 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
1616
1717 // Validate direction parameter
1818 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,
2323 }
2424 if !validDirections[direction] {
2525 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) {
124124 return
125125 }
126126
127- // Extract direction from action (e.g., "split-right" -> "right")
127+ // Extract direction from action (e.g., "split-right" -> DirectionRight)
128128 var direction string
129129 switch action {
130130 case "split-right":
131- direction = "right"
131+ direction = DirectionRight
132132 case "split-left":
133- direction = "left"
133+ direction = DirectionLeft
134134 case "split-up":
135- direction = "up"
135+ direction = DirectionUp
136136 case "split-down":
137- direction = "down"
137+ direction = DirectionDown
138138 default:
139139 log.Printf("[pane-mode] Invalid split action: %s", action)
140140 return
M internal/app/browser/workspace_pane_ops.go +4 -0
@@ -299,6 +299,8 @@ func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
299299 // This eliminates widget reparenting of existing WebViews, which was causing rendering corruption.
300300 // See splitNode() for the unified implementation.
301301
302+// TODO(#8): Refactor this function to reduce cyclomatic complexity (65 -> <30)
303+//
302304 // splitNode splits a target pane into two panes in the specified direction
303305 // splitNode splits a target pane into two panes in the specified direction.
304306 // If existingPane is provided, it will be used instead of creating a new WebView.
@@ -922,6 +924,8 @@ func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
922924 }
923925 }
924926
927+// TODO(#8): Refactor this function to reduce cyclomatic complexity (31 -> <30)
928+//
925929 // closePane closes a specific pane and handles tree restructuring (simplified implementation)
926930 func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
927931 ctx := wm.beginClose(node)
M internal/app/browser/workspace_popup.go +7 -7
@@ -37,7 +37,7 @@ func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView
3737 }
3838 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
3939 if direction == "" {
40- direction = "right"
40+ direction = DirectionRight
4141 }
4242 // Use splitNode with existing pane instead of insertPopupPane
4343 if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
@@ -65,7 +65,7 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
6565 }
6666 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
6767 if direction == "" {
68- direction = "right"
68+ direction = DirectionRight
6969 }
7070 // Use splitNode with existing pane instead of insertPopupPane
7171 if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
@@ -427,7 +427,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
427427 // Split pane behavior (default) - use splitNode with existing popup pane
428428 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
429429 if direction == "" {
430- direction = "right"
430+ direction = DirectionRight
431431 }
432432 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
433433 log.Printf("[workspace] Failed to insert split popup pane: %v", err)
@@ -444,7 +444,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
444444 // Insert the popup pane in the same container - use splitNode with existing popup pane
445445 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
446446 if direction == "" {
447- direction = "right"
447+ direction = DirectionRight
448448 }
449449 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
450450 log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
@@ -456,7 +456,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
456456 log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
457457 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
458458 if direction == "" {
459- direction = "right"
459+ direction = DirectionRight
460460 }
461461 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
462462 log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
@@ -469,7 +469,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
469469 log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
470470 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
471471 if direction == "" {
472- direction = "right"
472+ direction = DirectionRight
473473 }
474474 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
475475 log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
@@ -481,7 +481,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
481481 log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
482482 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
483483 if direction == "" {
484- direction = "right"
484+ direction = DirectionRight
485485 }
486486 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
487487 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) {
3535 // mapDirection maps a direction string to GTK orientation and positioning
3636 func mapDirection(direction string) (gtk.Orientation, bool) {
3737 switch direction {
38- case "left":
38+ case DirectionLeft:
3939 return gtk.OrientationHorizontal, false
40- case "up":
40+ case DirectionUp:
4141 return gtk.OrientationVertical, false
42- case "down":
42+ case DirectionDown:
4343 return gtk.OrientationVertical, true
4444 default:
4545 return gtk.OrientationHorizontal, true
M internal/app/control/navigation.go +6 -2
@@ -55,7 +55,9 @@ func (n *NavigationController) NavigateToURL(input string) error {
5555 haveZoom = true
5656 if n.webView != nil {
5757 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+ }
5961 } else {
6062 n.webView.RunOnMainThread(func() {
6163 if err := n.webView.SetZoom(zoomLevel); err != nil {
@@ -108,7 +110,9 @@ func (n *NavigationController) HandleBrowseCommand() {
108110 zoomLevel = level
109111 haveZoom = true
110112 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+ }
112116 } else {
113117 n.webView.RunOnMainThread(func() {
114118 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) {
8282 case "navigate":
8383 h.handleNavigation(msg)
8484 case "query":
85- log.Printf("[DEBUG] Handling query message: q=%s, limit=%d", msg.Q, msg.Limit)
8685 if h.webView == nil {
87- log.Printf("[WARN] Dropping query response: webview unavailable for suggestions")
8886 return
8987 }
9088 h.handleQuery(msg)
@@ -200,13 +198,7 @@ func (h *Handler) handleClosePopup(msg Message) {
200198 }
201199
202200 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 {
210202 return
211203 }
212204 ctx := context.Background()
@@ -233,12 +225,8 @@ func (h *Handler) handleQuery(msg Message) {
233225 // History
234226 if len(results) < limit {
235227 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 {
242230 // JSON roundtrip to map to get url field agnostic of struct tag
243231 bb, _ := json.Marshal(e)
244232 var m map[string]any
@@ -249,7 +237,6 @@ func (h *Handler) handleQuery(msg Message) {
249237 } else if s, ok := m["URL"].(string); ok {
250238 url = s
251239 }
252- log.Printf("[DEBUG] History entry %d: url=%s", i, url)
253240 if url == "" {
254241 continue
255242 }
@@ -271,17 +258,11 @@ func (h *Handler) handleQuery(msg Message) {
271258 }
272259
273260 // 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 {
278262 // Prefer unified page-world API; fallback to legacy global function
279263 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)
285266 }
286267 }
287268 }
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) {
8989 if err != nil {
9090 return nil, fmt.Errorf("failed to open cache file: %w", err)
9191 }
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+ }()
9397
9498 // Get file size
9599 stat, err := file.Stat()
@@ -135,7 +139,9 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) {
135139 // Parse filter data without copying
136140 data, err := fc.parseInPlace(header)
137141 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+ }
139145 return nil, fmt.Errorf("failed to parse cache data: %w", err)
140146 }
141147
@@ -221,7 +227,11 @@ func (fc *FilterCache) Write(data *FilterData) error {
221227 if err != nil {
222228 return fmt.Errorf("failed to create cache file: %w", err)
223229 }
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+ }()
225235
226236 // Calculate total rule count
227237 totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))
@@ -237,7 +247,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
237247 }
238248
239249 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+ }
241253 return fmt.Errorf("failed to write header: %w", err)
242254 }
243255
@@ -246,7 +258,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
246258 // Write network rules
247259 for _, rule := range data.NetworkRules {
248260 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+ }
250264 return fmt.Errorf("failed to write network rule: %w", err)
251265 }
252266 }
@@ -254,7 +268,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
254268 // Write cosmetic rules
255269 for _, rule := range data.CosmeticRules {
256270 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+ }
258274 return fmt.Errorf("failed to write cosmetic rule: %w", err)
259275 }
260276 }
@@ -265,7 +281,9 @@ func (fc *FilterCache) Write(data *FilterData) error {
265281
266282 // Update header with data size
267283 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+ }
269287 return err
270288 }
271289
@@ -273,20 +291,28 @@ func (fc *FilterCache) Write(data *FilterData) error {
273291 // TODO: Calculate and set checksum
274292
275293 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+ }
277297 return fmt.Errorf("failed to update header: %w", err)
278298 }
279299
280300 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+ }
282304 return fmt.Errorf("failed to sync cache file: %w", err)
283305 }
284306
285- file.Close()
307+ if err := file.Close(); err != nil {
308+ logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
309+ }
286310
287311 // Atomic rename
288312 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+ }
290316 return fmt.Errorf("failed to rename cache file: %w", err)
291317 }
292318
@@ -382,7 +408,11 @@ func (fc *FilterCache) IsValid() bool {
382408 if err != nil {
383409 return false
384410 }
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+ }()
386416
387417 var magic uint64
388418 if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {
@@ -394,6 +424,8 @@ func (fc *FilterCache) IsValid() bool {
394424
395425 // Remove deletes the cache file
396426 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+ }
398430 return os.Remove(fc.path)
399431 }
M internal/cache/fuzzy_cache.go +2 -2
@@ -2,7 +2,7 @@ package cache
22
33 import (
44 "context"
5- "crypto/md5"
5+ "crypto/sha256"
66 "fmt"
77 "os"
88 "path/filepath"
@@ -286,7 +286,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
286286 }
287287
288288 // Create hash from URLs, visit counts, and last visited times
289- hasher := md5.New()
289+ hasher := sha256.New()
290290 for _, entry := range recentHistory {
291291 hasher.Write([]byte(entry.Url))
292292 if entry.VisitCount.Valid {
M internal/cache/fuzzy_cache_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 mock_cache "github.com/bnema/dumber/internal/cache/mocks"
1212 "github.com/bnema/dumber/internal/db"
13- "github.com/golang/mock/gomock"
13+ "go.uber.org/mock/gomock"
1414 )
1515
1616 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 (
1111 // HistoryQuerier defines the interface for querying history data.
1212 type HistoryQuerier interface {
1313 GetHistory(ctx context.Context, limit int64) ([]db.History, error)
14- GetShortcuts(ctx context.Context) ([]db.Shortcut, error)
1514 }
M internal/cache/mocks/mock_querier.go +8 -17
@@ -1,5 +1,10 @@
11 // Code generated by MockGen. DO NOT EDIT.
22 // Source: interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=interfaces.go -destination=mocks/mock_querier.go
7+//
38
49 // Package mock_cache is a generated GoMock package.
510 package mock_cache
@@ -9,13 +14,14 @@ import (
914 reflect "reflect"
1015
1116 db "github.com/bnema/dumber/internal/db"
12- gomock "github.com/golang/mock/gomock"
17+ gomock "go.uber.org/mock/gomock"
1318 )
1419
1520 // MockHistoryQuerier is a mock of HistoryQuerier interface.
1621 type MockHistoryQuerier struct {
1722 ctrl *gomock.Controller
1823 recorder *MockHistoryQuerierMockRecorder
24+ isgomock struct{}
1925 }
2026
2127 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
@@ -45,22 +51,7 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.
4551 }
4652
4753 // 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 {
4955 mr.mock.ctrl.T.Helper()
5056 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
5157 }
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 {
172172 return fmt.Errorf("failed to get history: %w", err)
173173 }
174174
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
180177
181178 // Generate options
182179 options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
@@ -190,15 +187,15 @@ func generateOptionsFallback(cli *CLI) error {
190187 })
191188
192189 // 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 == "" {
196193 desc = "Custom shortcut"
197194 }
198195
199196 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 + ":",
202199 Type: "shortcut",
203200 Description: desc,
204201 })
M internal/cli/logs.go +10 -2
@@ -163,7 +163,11 @@ func tailFile(filePath string, lines int) error {
163163 }
164164 return fmt.Errorf("failed to open log file: %w", err)
165165 }
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+ }()
167171
168172 // Read all lines
169173 var allLines []string
@@ -335,7 +339,11 @@ func tailConsoleMessages(filePath string, lines int, level string) error {
335339 }
336340 return fmt.Errorf("failed to open log file: %w", err)
337341 }
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+ }()
339347
340348 // Read all lines and filter for console messages
341349 var consoleLines []string
M internal/cli/purge.go +17 -19
@@ -45,10 +45,13 @@ func NewPurgeCmd() *cobra.Command {
4545 Long: `Purge various dumber data and cache files. By default, purges everything.
4646
4747 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)
5052 --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
5255 --filter-cache, -F Purge compiled ad blocking filters cache
5356 --state, -s Purge all state data (includes database and caches)
5457 --config Purge configuration files
@@ -56,34 +59,29 @@ Available purge targets:
5659
5760 Use --force to skip the confirmation prompt.
5861
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+
5967 Examples:
6068 dumber purge # Purge everything (with confirmation)
6169 dumber purge --force # Purge everything (no confirmation)
6270 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)`,
6473 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-
7674 return executePurge(flags)
7775 },
7876 }
7977
8078 // 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)")
8381 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)")
8583 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)")
8785 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
8886 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
8987 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) {
6565 return nil, fmt.Errorf("failed to connect to database: %w", err)
6666 }
6767
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-
7668 queries := db.New(database)
7769 parserService := services.NewParserService(cfg, queries)
7870
@@ -95,28 +87,11 @@ func (c *CLI) Close() error {
9587 // NewRootCmd creates the root command for dumber
9688 func NewRootCmd(version, commit, buildDate string) *cobra.Command {
9789 rootCmd := &cobra.Command{
98- Use: "dumber [url]",
90+ Use: "dumber",
9991 Short: "A dumb browser for Wayland window managers",
10092 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
12095 return cmd.Help()
12196 },
12297 }
@@ -183,20 +158,3 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
183158
184159 return rootCmd
185160 }
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 (
88 "path/filepath"
99 "strings"
1010 "sync"
11- "time"
1211
1312 "github.com/bnema/dumber/internal/logging"
1413 "github.com/bnema/dumber/pkg/gpu"
@@ -32,9 +31,7 @@ type Config struct {
3231 Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
3332 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
3433 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
35- WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"`
3634 Debug DebugConfig `mapstructure:"debug" yaml:"debug"`
37- APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"`
3835 // RenderingMode controls GPU/CPU rendering selection for WebKit
3936 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
4037 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
@@ -58,10 +55,7 @@ const (
5855
5956 // DatabaseConfig holds database-related configuration.
6057 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"`
6559 }
6660
6761 // HistoryConfig holds history-related configuration.
@@ -178,35 +172,6 @@ type CodecConfig struct {
178172 DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"`
179173 }
180174
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-
210175 // DebugConfig holds debug and troubleshooting options
211176 type DebugConfig struct {
212177 // Enable WebKit internal debug logging
@@ -247,14 +212,6 @@ type DebugConfig struct {
247212 EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`
248213 }
249214
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-
258215 // WorkspaceConfig captures layout, pane, and tab behaviour preferences.
259216 type WorkspaceConfig struct {
260217 // EnableZellijControls toggles Zellij-inspired keybindings.
@@ -371,12 +328,6 @@ func NewManager() (*Manager, error) {
371328 // Bind specific environment variables
372329 bindings := map[string]string{
373330 "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",
380331 "history.max_entries": "HISTORY_MAX_ENTRIES",
381332 "history.retention_period": "HISTORY_RETENTION_PERIOD",
382333 "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
@@ -395,9 +346,6 @@ func NewManager() (*Manager, error) {
395346 "logging.max_backups": "LOGGING_MAX_BACKUPS",
396347 "logging.max_age": "LOGGING_MAX_AGE",
397348 "logging.compress": "LOGGING_COMPRESS",
398- // API security
399- "api_security.token": "API_TOKEN",
400- "api_security.require_token": "API_REQUIRE_TOKEN",
401349 }
402350
403351 for key, env := range bindings {
@@ -453,26 +401,6 @@ func NewManager() (*Manager, error) {
453401 }
454402 }
455403
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-
476404 return &Manager{
477405 viper: v,
478406 callbacks: make([]func(*Config), 0),
@@ -659,10 +587,7 @@ func (m *Manager) reload() error {
659587 func (m *Manager) setDefaults() {
660588 defaults := DefaultConfig()
661589
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
666591
667592 // Note: Browser config removed - we build our own browser
668593
@@ -743,18 +668,6 @@ func (m *Manager) setDefaults() {
743668 m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution)
744669 m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl)
745670
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-
758671 // Rendering defaults
759672 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
760673 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
@@ -802,6 +715,13 @@ func (m *Manager) createDefaultConfig() error {
802715 }
803716
804717 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+
805725 return nil
806726 }
807727
@@ -907,17 +827,17 @@ func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
907827 // validateColorScheme validates and normalizes the ColorScheme setting
908828 func (m *Manager) validateColorScheme(config *Config) {
909829 switch config.Appearance.ColorScheme {
910- case "prefer-dark", "prefer-light", "default", "":
830+ case "prefer-dark", "prefer-light", ThemeDefault, "":
911831 // Valid values - no changes needed
912832 // Empty string is treated the same as "default"
913833 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)")
916836 }
917837 default:
918838 // 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+"'",
920840 config.Appearance.ColorScheme))
921- config.Appearance.ColorScheme = "default"
841+ config.Appearance.ColorScheme = ThemeDefault
922842 }
923843 }
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 @@
11 // Package config provides default configuration values for dumber.
22 package config
33
4-import (
5- "time"
6-)
7-
84 // Default configuration constants
95 const (
10- // Database defaults
11- defaultMaxIdleTimeMin = 5 // minutes
12- defaultQueryTimeoutSec = 30 // seconds
13-
146 // History defaults
157 defaultMaxHistoryEntries = 10000 // entries
168 defaultRetentionDays = 365 // 1 year
@@ -27,7 +19,7 @@ const (
2719 defaultFontSize = 16 // points
2820
2921 // Workspace defaults
30- defaultPaneActivationShortcut = "cmdorctrl+p"
22+ defaultPaneActivationShortcut = "ctrl+p"
3123 defaultPaneTimeoutMilliseconds = 3000
3224 defaultPopupPlacement = "right"
3325
@@ -52,9 +44,7 @@ func getDefaultLogDir() string {
5244 func DefaultConfig() *Config {
5345 return &Config{
5446 Database: DatabaseConfig{
55- MaxConnections: 1,
56- MaxIdleTime: time.Minute * defaultMaxIdleTimeMin,
57- QueryTimeout: time.Second * defaultQueryTimeoutSec,
47+ // Path is set dynamically in config.Load()
5848 },
5949 History: HistoryConfig{
6050 MaxEntries: defaultMaxHistoryEntries,
@@ -131,20 +121,8 @@ func DefaultConfig() *Config {
131121 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
132122 QueueBufferTimeSec: 10, // More buffering time for smooth playback
133123 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)
148126 },
149127 Debug: DebugConfig{
150128 EnableWebKitDebug: false,
@@ -160,10 +138,6 @@ func DefaultConfig() *Config {
160138 EnableFocusMetrics: false,
161139 EnablePaneCloseDebug: false,
162140 },
163- APISecurity: APISecurityConfig{
164- Token: "",
165- RequireToken: false,
166- },
167141 RenderingMode: RenderingModeGPU,
168142 UseDomZoom: false,
169143 DefaultZoom: 1.2, // 120% default zoom for better readability
@@ -187,10 +161,10 @@ func DefaultConfig() *Config {
187161 },
188162 },
189163 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",
194168 },
195169 Popups: PopupBehaviorConfig{
196170 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 (
66 "path/filepath"
77 )
88
9-const appName = "dumber"
9+const (
10+ appName = "dumber"
11+ databaseName = "dumber.sqlite"
12+)
1013
1114 // XDGDirs holds the XDG Base Directory paths for the application.
1215 type XDGDirs struct {
@@ -114,13 +117,15 @@ func GetConfigFile() (string, error) {
114117 return filepath.Join(configDir, "config.json"), nil
115118 }
116119
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.
118123 func GetDatabaseFile() (string, error) {
119- stateDir, err := GetStateDir()
124+ dataDir, err := GetDataDir()
120125 if err != nil {
121126 return "", err
122127 }
123- return filepath.Join(stateDir, "history.db"), nil
128+ return filepath.Join(dataDir, databaseName), nil
124129 }
125130
126131 // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
M internal/db/certificate_validations.sql.go +37 -1
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec
14-DELETE FROM certificate_validations
14+DELETE FROM certificate_validations
1515 WHERE hostname = ? AND certificate_hash = ?
1616 `
1717
@@ -73,6 +73,42 @@ func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostna
7373 return i, err
7474 }
7575
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+
76112 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
77113 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
78114 VALUES (?, ?, ?, ?)
M internal/db/history.sql.go +0 -35
@@ -133,41 +133,6 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset
133133 return items, nil
134134 }
135135
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-
171136 const SearchHistory = `-- name: SearchHistory :many
172137 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
173138 FROM history
M internal/db/init.go +22 -47
@@ -7,7 +7,6 @@ import (
77 "os"
88 "path/filepath"
99
10- "github.com/bnema/dumber/internal/config"
1110 "github.com/bnema/dumber/internal/migrations"
1211 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
1312 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
@@ -20,7 +19,7 @@ func InitDB(dbPath string) (*sql.DB, error) {
2019 }
2120
2221 // 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 {
2423 return nil, fmt.Errorf("failed to create database directory: %w", err)
2524 }
2625
@@ -38,59 +37,35 @@ func InitDB(dbPath string) (*sql.DB, error) {
3837 return nil, fmt.Errorf("failed to connect to database: %w", err)
3938 }
4039
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
4748 }
4849
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)
5356 }
54- return nil, fmt.Errorf("migration verification failed: %w", err)
5557 }
5658
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")
6660
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 {
7065 log.Printf("Warning: failed to close database: %v", err)
7166 }
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)
9368 }
9469
95- return nil
70+ return database, nil
9671 }
M internal/db/interfaces.go +10 -4
@@ -12,6 +12,7 @@ type ZoomQuerier interface {
1212 GetZoomLevel(ctx context.Context, domain string) (float64, error)
1313 SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error
1414 DeleteZoomLevel(ctx context.Context, domain string) error
15+ ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
1516 }
1617
1718 // HistoryQuerier defines the interface for history-related database operations
@@ -25,16 +26,21 @@ type HistoryQuerier interface {
2526 DeleteHistory(ctx context.Context, id int64) error
2627 }
2728
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
3137 }
3238
3339 // DatabaseQuerier combines all database operation interfaces
3440 type DatabaseQuerier interface {
3541 ZoomQuerier
3642 HistoryQuerier
37- ShortcutQuerier
43+ CertificateQuerier
3844 }
3945
4046 // Ensure that *Queries implements DatabaseQuerier interface
M internal/db/mocks/mock_db.go +337 -38
@@ -1,5 +1,10 @@
11 // Code generated by MockGen. DO NOT EDIT.
22 // Source: interfaces.go
3+//
4+// Generated by this command:
5+//
6+// mockgen -source=interfaces.go -destination=mocks/mock_db.go
7+//
38
49 // Package mock_db is a generated GoMock package.
510 package mock_db
@@ -10,13 +15,14 @@ import (
1015 reflect "reflect"
1116
1217 db "github.com/bnema/dumber/internal/db"
13- gomock "github.com/golang/mock/gomock"
18+ gomock "go.uber.org/mock/gomock"
1419 )
1520
1621 // MockZoomQuerier is a mock of ZoomQuerier interface.
1722 type MockZoomQuerier struct {
1823 ctrl *gomock.Controller
1924 recorder *MockZoomQuerierMockRecorder
25+ isgomock struct{}
2026 }
2127
2228 // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier.
@@ -45,7 +51,7 @@ func (m *MockZoomQuerier) DeleteZoomLevel(ctx context.Context, domain string) er
4551 }
4652
4753 // 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 {
4955 mr.mock.ctrl.T.Helper()
5056 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain)
5157 }
@@ -60,11 +66,26 @@ func (m *MockZoomQuerier) GetZoomLevel(ctx context.Context, domain string) (floa
6066 }
6167
6268 // 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 {
6470 mr.mock.ctrl.T.Helper()
6571 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain)
6672 }
6773
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+
6889 // SetZoomLevel mocks base method.
6990 func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error {
7091 m.ctrl.T.Helper()
@@ -74,7 +95,7 @@ func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomL
7495 }
7596
7697 // 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 {
7899 mr.mock.ctrl.T.Helper()
79100 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
80101 }
@@ -83,6 +104,7 @@ func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel inter
83104 type MockHistoryQuerier struct {
84105 ctrl *gomock.Controller
85106 recorder *MockHistoryQuerierMockRecorder
107+ isgomock struct{}
86108 }
87109
88110 // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
@@ -111,11 +133,25 @@ func (m *MockHistoryQuerier) AddOrUpdateHistory(ctx context.Context, url string,
111133 }
112134
113135 // 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 {
115137 mr.mock.ctrl.T.Helper()
116138 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
117139 }
118140
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+
119155 // GetHistory mocks base method.
120156 func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
121157 m.ctrl.T.Helper()
@@ -126,11 +162,41 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.
126162 }
127163
128164 // 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 {
130166 mr.mock.ctrl.T.Helper()
131167 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
132168 }
133169
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+
134200 // SearchHistory mocks base method.
135201 func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
136202 m.ctrl.T.Helper()
@@ -141,53 +207,141 @@ func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2
141207 }
142208
143209 // 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 {
145211 mr.mock.ctrl.T.Helper()
146212 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
147213 }
148214
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 {
151231 ctrl *gomock.Controller
152- recorder *MockShortcutQuerierMockRecorder
232+ recorder *MockCertificateQuerierMockRecorder
233+ isgomock struct{}
153234 }
154235
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
158239 }
159240
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}
164245 return mock
165246 }
166247
167248 // EXPECT returns an object that allows the caller to indicate expected use.
168-func (m *MockShortcutQuerier) EXPECT() *MockShortcutQuerierMockRecorder {
249+func (m *MockCertificateQuerier) EXPECT() *MockCertificateQuerierMockRecorder {
169250 return m.recorder
170251 }
171252
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 {
174255 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)
177316 ret1, _ := ret[1].(error)
178317 return ret0, ret1
179318 }
180319
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 {
183336 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)
185338 }
186339
187340 // MockDatabaseQuerier is a mock of DatabaseQuerier interface.
188341 type MockDatabaseQuerier struct {
189342 ctrl *gomock.Controller
190343 recorder *MockDatabaseQuerierMockRecorder
344+ isgomock struct{}
191345 }
192346
193347 // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier.
@@ -216,11 +370,53 @@ func (m *MockDatabaseQuerier) AddOrUpdateHistory(ctx context.Context, url string
216370 }
217371
218372 // 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 {
220374 mr.mock.ctrl.T.Helper()
221375 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
222376 }
223377
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+
224420 // DeleteZoomLevel mocks base method.
225421 func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error {
226422 m.ctrl.T.Helper()
@@ -230,11 +426,41 @@ func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string
230426 }
231427
232428 // 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 {
234430 mr.mock.ctrl.T.Helper()
235431 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain)
236432 }
237433
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+
238464 // GetHistory mocks base method.
239465 func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
240466 m.ctrl.T.Helper()
@@ -245,24 +471,39 @@ func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db
245471 }
246472
247473 // 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 {
249475 mr.mock.ctrl.T.Helper()
250476 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit)
251477 }
252478
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) {
255481 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)
258484 ret1, _ := ret[1].(error)
259485 return ret0, ret1
260486 }
261487
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 {
264490 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)
266507 }
267508
268509 // GetZoomLevel mocks base method.
@@ -275,11 +516,41 @@ func (m *MockDatabaseQuerier) GetZoomLevel(ctx context.Context, domain string) (
275516 }
276517
277518 // 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 {
279520 mr.mock.ctrl.T.Helper()
280521 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain)
281522 }
282523
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+
283554 // SearchHistory mocks base method.
284555 func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
285556 m.ctrl.T.Helper()
@@ -290,7 +561,7 @@ func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column
290561 }
291562
292563 // 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 {
294565 mr.mock.ctrl.T.Helper()
295566 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
296567 }
@@ -304,7 +575,35 @@ func (m *MockDatabaseQuerier) SetZoomLevel(ctx context.Context, domain string, z
304575 }
305576
306577 // 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 {
308579 mr.mock.ctrl.T.Helper()
309580 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
310581 }
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 {
1717 ExpiresAt sql.NullTime `json:"expires_at"`
1818 }
1919
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+
2035 type History struct {
2136 ID int64 `json:"id"`
2237 Url string `json:"url"`
@@ -27,14 +42,6 @@ type History struct {
2742 FaviconUrl sql.NullString `json:"favicon_url"`
2843 }
2944
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-
3845 type ZoomLevel struct {
3946 Domain string `json:"domain"`
4047 ZoomFactor float64 `json:"zoom_factor"`
M internal/db/querier.go +1 -1
@@ -23,11 +23,11 @@ type Querier interface {
2323 GetHistory(ctx context.Context, limit int64) ([]History, error)
2424 GetHistoryEntry(ctx context.Context, url string) (History, error)
2525 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
26- GetShortcuts(ctx context.Context) ([]Shortcut, error)
2726 // Get zoom level for a specific domain
2827 GetZoomLevel(ctx context.Context, domain string) (float64, error)
2928 // Get zoom level for domain with default fallback
3029 GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
30+ ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error)
3131 // List all zoom level settings ordered by most recently updated
3232 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
3333 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
2020 WHERE expires_at IS NOT NULL AND expires_at <= datetime('now');
2121
2222 -- 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;
3939
4040 -- name: DeleteHistory :exec
4141 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 (
44 "context"
55 "crypto/sha256"
66 "fmt"
7+ "log"
78 "net/http"
89 "strings"
910 "sync"
@@ -138,7 +139,11 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi
138139 if err != nil {
139140 return nil, fmt.Errorf("HEAD request failed: %w", err)
140141 }
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+ }()
142147
143148 if resp.StatusCode != http.StatusOK {
144149 return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode)
@@ -183,7 +188,11 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
183188 if err != nil {
184189 return nil, fmt.Errorf("GET request failed: %w", err)
185190 }
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+ }()
187196
188197 if resp.StatusCode != http.StatusOK {
189198 return nil, fmt.Errorf("GET request returned %d", resp.StatusCode)
M internal/logging/capture.go +37 -12
@@ -4,6 +4,7 @@ import (
44 "bufio"
55 "fmt"
66 "io"
7+ "log"
78 "os"
89 "strings"
910 "time"
@@ -46,8 +47,12 @@ func (c *OutputCapture) Start() error {
4647 // Create pipes for stderr
4748 stderrR, stderrW, err := os.Pipe()
4849 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+ }
5156 return err
5257 }
5358
@@ -62,8 +67,12 @@ func (c *OutputCapture) Start() error {
6267 os.Stderr = stderrW
6368
6469 // 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+ }
6776
6877 // Start goroutines to read and log
6978 go c.pipeToLogger(stdoutR, "STDOUT")
@@ -85,21 +94,33 @@ func (c *OutputCapture) Stop() {
8594 os.Stderr = c.originalStderr
8695
8796 // 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+ }
90103
91104 // Close pipes
92105 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+ }
94109 }
95110 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+ }
97114 }
98115 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+ }
100119 }
101120 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+ }
103124 }
104125
105126 c.started = false
@@ -121,9 +142,13 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) {
121142 timestamp := time.Now().Format("2006-01-02 15:04:05")
122143 logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line)
123144 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+ }
125148 } 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+ }
127152 }
128153 }
129154 }
M internal/logging/crash.go +3 -1
@@ -67,7 +67,9 @@ func handleCrash(sig os.Signal) {
6767
6868 // Force flush logs
6969 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+ }
7173 }
7274
7375 // 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
9797 globalLoggerMux.Lock()
9898 defer globalLoggerMux.Unlock()
9999
100- err := os.MkdirAll(logDir, 0755)
100+ err := os.MkdirAll(logDir, 0750)
101101 if err != nil {
102102 return fmt.Errorf("failed to create log directory: %w", err)
103103 }
@@ -158,7 +158,9 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) {
158158 formatted := l.formatter.Format(level, message, source)
159159
160160 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+ }
162164 }
163165 }
164166
@@ -180,7 +182,9 @@ func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) {
180182 // Write only to file outputs (skip stdout)
181183 for _, output := range l.outputs {
182184 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+ }
184188 }
185189 }
186190 }
M internal/logging/rotator.go +28 -8
@@ -49,7 +49,7 @@ func (r *LogRotator) openCurrentFile() error {
4949 r.currentSize = info.Size()
5050 }
5151
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)
5353 if err != nil {
5454 return fmt.Errorf("failed to open log file: %w", err)
5555 }
@@ -86,7 +86,9 @@ func (r *LogRotator) Write(p []byte) (n int, err error) {
8686
8787 func (r *LogRotator) rotate() error {
8888 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+ }
9092 }
9193
9294 // Generate backup filename with timestamp
@@ -108,7 +110,9 @@ func (r *LogRotator) rotate() error {
108110 fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err)
109111 } else {
110112 // 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+ }
112116 }
113117 }
114118
@@ -125,17 +129,29 @@ func (r *LogRotator) compressFile(filePath string) error {
125129 if err != nil {
126130 return err
127131 }
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+ }()
129137
130138 outputPath := filePath + ".gz"
131139 outputFile, err := os.Create(outputPath)
132140 if err != nil {
133141 return err
134142 }
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+ }()
136148
137149 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+ }()
139155
140156 _, err = io.Copy(gzipWriter, inputFile)
141157 return err
@@ -167,7 +183,9 @@ func (r *LogRotator) cleanup() {
167183
168184 // Remove files older than maxAge
169185 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+ }
171189 continue
172190 }
173191
@@ -183,7 +201,9 @@ func (r *LogRotator) cleanup() {
183201
184202 // Remove oldest files
185203 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+ }
187207 }
188208 }
189209 }
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 {
132132 }
133133 defer func() {
134134 if err != nil {
135- tx.Rollback()
135+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
136+ log.Printf("failed to rollback migration transaction: %v", rollbackErr)
137+ }
136138 }
137139 }()
138140
@@ -175,7 +177,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
175177 if err != nil {
176178 return nil, fmt.Errorf("failed to query applied migrations: %w", err)
177179 }
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+ }()
179185
180186 var versions []int
181187 for rows.Next() {
M internal/migrations/migrations_test.go +13 -13
@@ -20,7 +20,7 @@ func TestMigrationsAreIdempotent(t *testing.T) {
2020 if err != nil {
2121 t.Fatalf("Failed to open test database: %v", err)
2222 }
23- defer db.Close()
23+ defer func() { _ = db.Close() }()
2424
2525 // Run migrations first time
2626 if err := RunEmbeddedMigrations(db); err != nil {
@@ -83,7 +83,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) {
8383 if err != nil {
8484 t.Fatalf("Failed to open test database: %v", err)
8585 }
86- defer db.Close()
86+ defer func() { _ = db.Close() }()
8787
8888 // Run migrations
8989 if err := RunEmbeddedMigrations(db); err != nil {
@@ -106,7 +106,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) {
106106 if err != nil {
107107 t.Fatalf("Failed to check for duplicate migration entries: %v", err)
108108 }
109- defer rows.Close()
109+ defer func() { _ = rows.Close() }()
110110
111111 duplicates := []int{}
112112 for rows.Next() {
@@ -131,7 +131,7 @@ func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) {
131131 if err != nil {
132132 t.Fatalf("Failed to open test database: %v", err)
133133 }
134- defer db.Close()
134+ defer func() { _ = db.Close() }()
135135
136136 // Get expected migrations
137137 expectedMigrations, err := GetMigrations()
@@ -207,7 +207,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) {
207207 if err != nil {
208208 t.Fatalf("Failed to open test database: %v", err)
209209 }
210- defer db.Close()
210+ defer func() { _ = db.Close() }()
211211
212212 // Create tracking table multiple times
213213 for i := 0; i < 3; i++ {
@@ -236,7 +236,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) {
236236 if err != nil {
237237 t.Fatalf("Failed to get table info: %v", err)
238238 }
239- defer rows.Close()
239+ defer func() { _ = rows.Close() }()
240240
241241 expectedColumns := map[string]bool{
242242 "version": false,
@@ -275,7 +275,7 @@ func TestPartialMigrationState(t *testing.T) {
275275 if err != nil {
276276 t.Fatalf("Failed to open test database: %v", err)
277277 }
278- defer db.Close()
278+ defer func() { _ = db.Close() }()
279279
280280 // Create tracking table
281281 if err := createMigrationsTable(db); err != nil {
@@ -300,12 +300,12 @@ func TestPartialMigrationState(t *testing.T) {
300300 }
301301
302302 if _, err := tx.Exec(firstMigration.SQL); err != nil {
303- tx.Rollback()
303+ _ = tx.Rollback()
304304 t.Fatalf("Failed to execute first migration: %v", err)
305305 }
306306
307307 if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {
308- tx.Rollback()
308+ _ = tx.Rollback()
309309 t.Fatalf("Failed to record first migration: %v", err)
310310 }
311311
@@ -353,7 +353,7 @@ func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) {
353353 if err != nil {
354354 t.Fatalf("Failed to open test database: %v", err)
355355 }
356- defer db.Close()
356+ defer func() { _ = db.Close() }()
357357
358358 // Query applied migrations on empty database
359359 applied, err := GetAppliedMigrations(db)
@@ -375,7 +375,7 @@ func TestVerifyAllMigrationsApplied(t *testing.T) {
375375 if err != nil {
376376 t.Fatalf("Failed to open test database: %v", err)
377377 }
378- defer db.Close()
378+ defer func() { _ = db.Close() }()
379379
380380 // Run migrations
381381 if err := RunEmbeddedMigrations(db); err != nil {
@@ -409,7 +409,7 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
409409 if err != nil {
410410 t.Fatalf("Failed to open test database: %v", err)
411411 }
412- defer db.Close()
412+ defer func() { _ = db.Close() }()
413413
414414 // Run migrations
415415 if err := RunEmbeddedMigrations(db); err != nil {
@@ -420,9 +420,9 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
420420 expectedTables := []string{
421421 "schema_migrations", // Migration tracking
422422 "history", // 001_initial.sql
423- "shortcuts", // 001_initial.sql
424423 "zoom_levels", // 003_add_zoom_levels.sql
425424 "certificate_validations", // 005_add_certificate_validations.sql
425+ // Note: shortcuts table removed in 007_remove_shortcuts_table.sql
426426 }
427427
428428 // Check each expected table exists
M internal/services/browser.go +219 -23
@@ -5,13 +5,14 @@ import (
55 "context"
66 "database/sql"
77 "embed"
8- "errors"
98 "fmt"
109 "log"
1110 "math"
1211 neturl "net/url"
12+ "sync"
1313 "time"
1414
15+ "github.com/bnema/dumber/internal/cache"
1516 "github.com/bnema/dumber/internal/config"
1617 "github.com/bnema/dumber/internal/db"
1718 "github.com/bnema/dumber/pkg/webkit"
@@ -33,6 +34,12 @@ type WindowTitleUpdater interface {
3334 SetTitle(title string)
3435 }
3536
37+// historyUpdate represents a pending history write operation
38+type historyUpdate struct {
39+ url string
40+ title sql.NullString
41+}
42+
3643 // BrowserService handles browser-related operations for the built-in browser.
3744 type BrowserService struct {
3845 config *config.Config
@@ -41,6 +48,16 @@ type BrowserService struct {
4148 webView *webkit.WebView
4249 initialURL string
4350 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
4461 }
4562
4663 // ServiceName returns the service name for frontend binding
@@ -71,14 +88,26 @@ type HistoryEntry struct {
7188
7289 // NewBrowserService creates a new BrowserService instance.
7390 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
81105 }
106+
107+ // Start background batch processor for history writes
108+ go s.processHistoryQueue()
109+
110+ return s
82111 }
83112
84113 // SetWindowTitleUpdater sets the window title updater interface
@@ -359,14 +388,113 @@ func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]con
359388 }
360389
361390 // recordHistory adds or updates a history entry.
391+// Now queues the update for batched processing instead of immediate DB write.
362392 func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
363- // Use AddOrUpdateHistory which handles both cases
364393 titleNull := sql.NullString{Valid: false}
365394 if title != "" {
366395 titleNull = sql.NullString{String: title, Valid: true}
367396 }
368397
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
370498 }
371499
372500 // 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
409537 return s.setZoom(ctx, url, 1.0)
410538 }
411539
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+
412604 // GetZoomLevel retrieves the saved zoom level for a URL.
605+// Now reads from in-memory cache instead of database for instant access.
413606 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
414607 if url == "" {
415608 return s.config.DefaultZoom, nil
416609 }
417610
418611 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
429622 }
430- return s.config.DefaultZoom, err
431623 }
432624
433- return zoomLevel, nil
625+ // No zoom setting found, return configured default
626+ return s.config.DefaultZoom, nil
434627 }
435628
436629 // SetZoomLevel sets the zoom level for a URL.
630+// Updates cache immediately (instant) and persists to DB asynchronously.
437631 func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
438632 if url == "" {
439633 return fmt.Errorf("URL cannot be empty")
@@ -447,7 +641,9 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel
447641 }
448642
449643 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)
451647 }
452648
453649 // 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
44 import (
55 "context"
66 "database/sql"
7+ "errors"
78 "fmt"
89 "log"
910 "os"
@@ -52,7 +53,7 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue
5253
5354 // Create export directory for CLI access
5455 exportDir := filepath.Join(dataDir, "favicons-export")
55- if err := os.MkdirAll(exportDir, 0755); err != nil {
56+ if err := os.MkdirAll(exportDir, 0750); err != nil {
5657 return nil, fmt.Errorf("failed to create favicon export directory: %w", err)
5758 }
5859
@@ -291,7 +292,7 @@ func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string)
291292 if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {
292293 return entry.FaviconUrl.String, nil
293294 }
294- } else if err != sql.ErrNoRows {
295+ } else if !errors.Is(err, sql.ErrNoRows) {
295296 log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err)
296297 }
297298
@@ -439,7 +440,9 @@ func (fs *FaviconService) CleanOldExports() error {
439440 }
440441
441442 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+ }
443446 }
444447 }
445448
M pkg/webkit/keyboard_bridge.go +8 -6
@@ -11,9 +11,9 @@ import (
1111
1212 // Keyboard shortcut modifier string constants
1313 const (
14- modifierCmdOrCtrl = "cmdorctrl"
15- modifierShift = "shift"
16- modifierAlt = "alt"
14+ modifierCtrl = "ctrl"
15+ modifierShift = "shift"
16+ modifierAlt = "alt"
1717 )
1818
1919 // GDK key constants for special keys
@@ -94,6 +94,8 @@ var shortcutActions = map[string]string{
9494 altmod + "+" + keyArrowUp: directionUp,
9595 }
9696
97+// TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30)
98+//
9799 // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
98100 // This is critical for allowing JavaScript KeyboardService to receive keyboard events
99101 // that would otherwise be consumed by GTK shortcuts
@@ -127,9 +129,9 @@ func (w *WebView) AttachKeyboardBridge() {
127129 // Build modifier prefix using switch on modifier combination
128130 switch {
129131 case ctrl && shift:
130- parts = append(parts, modifierCmdOrCtrl, modifierShift)
132+ parts = append(parts, modifierCtrl, modifierShift)
131133 case ctrl && !shift:
132- parts = append(parts, modifierCmdOrCtrl)
134+ parts = append(parts, modifierCtrl)
133135 case alt:
134136 parts = append(parts, modifierAlt)
135137 case shift:
@@ -199,7 +201,7 @@ func (w *WebView) AttachKeyboardBridge() {
199201
200202 if shortcut != "" {
201203 // Special case: Ctrl+P enters pane mode
202- if shortcut == modifierCmdOrCtrl+"+"+keyP {
204+ if shortcut == modifierCtrl+"+"+keyP {
203205 w.mu.RLock()
204206 handler := w.onPaneModeShortcut
205207 w.mu.RUnlock()
M pkg/webkit/mainloop.go +0 -1
@@ -9,7 +9,6 @@ import (
99
1010 var (
1111 mainLoop *glib.MainLoop
12- mainThreadID = runtime.LockOSThread // Store the main thread
1312 isInitialized bool
1413 )
1514
M pkg/webkit/network_session.go +13 -0
@@ -102,3 +102,16 @@ func InitPersistentSession(dataDir, cacheDir string) error {
102102 func GetGlobalNetworkSession() *webkit.NetworkSession {
103103 return globalNetworkSession
104104 }
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
22
33 import (
44 "context"
5- "crypto/sha256"
65 "database/sql"
6+ "errors"
77 "fmt"
88 "log"
99 "net/url"
@@ -83,37 +83,6 @@ func extractHostname(uri string) string {
8383 return host
8484 }
8585
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-
11786 // checkStoredCertificateDecision checks if user has previously decided on this hostname
11887 func checkStoredCertificateDecision(hostname string) (string, bool) {
11988 cfg := config.Get()
@@ -133,7 +102,11 @@ func checkStoredCertificateDecision(hostname string) (string, bool) {
133102 log.Printf("[tls] Failed to open database: %v", err)
134103 return "", false
135104 }
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+ }()
137110
138111 queries := db.New(database)
139112 ctx := context.Background()
@@ -141,7 +114,7 @@ func checkStoredCertificateDecision(hostname string) (string, bool) {
141114 // Get any validation for this hostname (we don't verify cert hash anymore)
142115 validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
143116 if err != nil {
144- if err == sql.ErrNoRows {
117+ if errors.Is(err, sql.ErrNoRows) {
145118 return "", false
146119 }
147120 log.Printf("[tls] Error checking certificate validation: %v", err)
@@ -167,7 +140,11 @@ func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime)
167140 if err != nil {
168141 return fmt.Errorf("failed to open database: %w", err)
169142 }
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+ }()
171148
172149 queries := db.New(database)
173150 ctx := context.Background()
@@ -338,7 +315,11 @@ func CleanupExpiredCertificateValidations() error {
338315 if err != nil {
339316 return fmt.Errorf("failed to open database: %w", err)
340317 }
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+ }()
342323
343324 queries := db.New(database)
344325 ctx := context.Background()
M pkg/webkit/types.go +17 -8
@@ -45,6 +45,13 @@ type Config struct {
4545 // MinimumFontSize in pixels
4646 MinimumFontSize int
4747
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+
4855 // DataDir is the directory for persistent data (cookies, localStorage, etc.)
4956 DataDir string
5057
@@ -63,13 +70,15 @@ type Config struct {
6370 // GetDefaultConfig returns a Config with sensible defaults
6471 func GetDefaultConfig() *Config {
6572 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
7483 }
7584 }
M pkg/webkit/webview.go +19 -10
@@ -197,6 +197,13 @@ func (w *WebView) applyConfig() error {
197197 // Enable hardware acceleration if configured
198198 settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways)
199199
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+
200207 return nil
201208 }
202209
@@ -229,11 +236,6 @@ func (w *WebView) setupEventHandlers() {
229236 // Favicon changed - connect to FaviconDatabase
230237 w.setupFaviconHandlers()
231238
232- // Load changed
233- w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {
234- // Handle load events if needed
235- })
236-
237239 // TLS error handling - connect to load-failed-with-tls-errors signal
238240 w.setupTLSErrorHandler()
239241
@@ -418,8 +420,12 @@ func (w *WebView) Hide() error {
418420
419421 // Destroy destroys the WebView and releases resources
420422 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.
423429
424430 if w.destroyed {
425431 return nil
@@ -432,14 +438,17 @@ func (w *WebView) Destroy() error {
432438 delete(viewRegistry, w.id)
433439 viewMu.Unlock()
434440
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+
436445 return nil
437446 }
438447
439448 // IsDestroyed returns true if the WebView has been destroyed
440449 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
443452 return w.destroyed
444453 }
445454
M tests/integration/browser_controls_integration_test.go +8 -1
@@ -93,10 +93,17 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
9393 func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {
9494 _, queries, cleanup := setupTestDB(t)
9595
96- cfg := &config.Config{}
96+ cfg := &config.Config{
97+ DefaultZoom: 1.0, // Set default zoom level
98+ }
9799
98100 service := services.NewBrowserService(cfg, queries)
99101
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+
100107 return service, cleanup
101108 }
102109

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close