detached fcefb9a

Merge pull request #4 from bnema/refactor/favicon-webkit-native

refactor: favicon handling with WebKitGTK native integration
commit
fcefb9a0cf6af37cd8c4afcc5b6c20fdcbc04719
author
bnema · · 2025-10-18 11:00 UTC
committer
GitHub · · 2025-10-18 11:00 UTC
merge of
d035193 + ff56061 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +841 -771
M CHANGELOG.md +15 -0
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **Favicon refactoring**: WebKitGTK FaviconDatabase as single source of truth, removing fragile file-based cache and ico/bmp conversion dependencies
9+ - Native favicon detection and automatic PNG export at 32x32 for CLI (dmenu)
10+ - SVG favicon rasterization support
11+ - Async texture loading with callbacks for non-blocking UI updates
12+ - FaviconService handles favicon collection across all panes
13+
14+### Changed
15+- Removed old favicon cache system (go-ico, go-bmp dependencies)
16+- Simplified omnibox favicon handling: uses database favicon_url only
17+
18+### Fixed
19+- Dark mode flash on startup: Set GTK dark theme preference and WebView background color before window/content creation
20+- Cmd+Q and window close button now trigger graceful shutdown instead of killing the browser process
21+
722 ## [0.12.0] - 2025-10-16
823
924 ### Added
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Added
8+- **Favicon refactoring**: WebKitGTK FaviconDatabase as single source of truth, removing fragile file-based cache and ico/bmp conversion dependencies
9+ - Native favicon detection and automatic PNG export at 32x32 for CLI (dmenu)
10+ - SVG favicon rasterization support
11+ - Async texture loading with callbacks for non-blocking UI updates
12+ - FaviconService handles favicon collection across all panes
13+
14+### Changed
15+- Removed old favicon cache system (go-ico, go-bmp dependencies)
16+- Simplified omnibox favicon handling: uses database favicon_url only
17+
18+### Fixed
19+- Dark mode flash on startup: Set GTK dark theme preference and WebView background color before window/content creation
20+- Cmd+Q and window close button now trigger graceful shutdown instead of killing the browser process
21+
7 ## [0.12.0] - 2025-10-1622 ## [0.12.0] - 2025-10-16
8 23
9 ### Added24 ### Added
M go.mod +8 -5
@@ -1,6 +1,6 @@
11 module github.com/bnema/dumber
22
3-go 1.25.1
3+go 1.25.3
44
55 require (
66 github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440
@@ -8,9 +8,9 @@ require (
88 github.com/fsnotify/fsnotify v1.9.0
99 github.com/golang/mock v1.6.0
1010 github.com/ncruces/go-sqlite3 v0.29.0
11- github.com/sergeymakinen/go-ico v1.0.0-beta.0
1211 github.com/spf13/cobra v1.10.1
1312 github.com/spf13/viper v1.21.0
13+ golang.org/x/sys v0.36.0
1414 )
1515
1616 require (
@@ -22,7 +22,6 @@ require (
2222 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
2323 github.com/rogpeppe/go-internal v1.12.0 // indirect
2424 github.com/sagikazarmark/locafero v0.11.0 // indirect
25- github.com/sergeymakinen/go-bmp v1.0.0 // indirect
2625 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
2726 github.com/spf13/afero v1.15.0 // indirect
2827 github.com/spf13/cast v1.10.0 // indirect
@@ -32,9 +31,13 @@ require (
3231 go.yaml.in/yaml/v3 v3.0.4 // indirect
3332 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
3433 golang.org/x/sync v0.17.0 // indirect
35- golang.org/x/sys v0.36.0 // indirect
3634 golang.org/x/text v0.29.0 // indirect
3735 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
3836 )
3937
40-replace github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.1
38+replace (
39+ github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751
40+ github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f
41+ github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.2-0.20251017152300-b3012dbe285f
42+ github.com/diamondburned/gotk4/pkg => github.com/bnema/gotk4/pkg v0.3.4-0.20251017144723-f765b03fd751
43+)
@@ -1,6 +1,6 @@
1 module github.com/bnema/dumber1 module github.com/bnema/dumber
2 2
3-go 1.25.13+go 1.25.3
4 4
5 require (5 require (
6 github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf4406 github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440
@@ -8,9 +8,9 @@ require (
8 github.com/fsnotify/fsnotify v1.9.08 github.com/fsnotify/fsnotify v1.9.0
9 github.com/golang/mock v1.6.09 github.com/golang/mock v1.6.0
10 github.com/ncruces/go-sqlite3 v0.29.010 github.com/ncruces/go-sqlite3 v0.29.0
11- github.com/sergeymakinen/go-ico v1.0.0-beta.0
12 github.com/spf13/cobra v1.10.111 github.com/spf13/cobra v1.10.1
13 github.com/spf13/viper v1.21.012 github.com/spf13/viper v1.21.0
13+ golang.org/x/sys v0.36.0
14 )14 )
15 15
16 require (16 require (
@@ -22,7 +22,6 @@ require (
22 github.com/pelletier/go-toml/v2 v2.2.4 // indirect22 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
23 github.com/rogpeppe/go-internal v1.12.0 // indirect23 github.com/rogpeppe/go-internal v1.12.0 // indirect
24 github.com/sagikazarmark/locafero v0.11.0 // indirect24 github.com/sagikazarmark/locafero v0.11.0 // indirect
25- github.com/sergeymakinen/go-bmp v1.0.0 // indirect
26 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect25 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
27 github.com/spf13/afero v1.15.0 // indirect26 github.com/spf13/afero v1.15.0 // indirect
28 github.com/spf13/cast v1.10.0 // indirect27 github.com/spf13/cast v1.10.0 // indirect
@@ -32,9 +31,13 @@ require (
32 go.yaml.in/yaml/v3 v3.0.4 // indirect31 go.yaml.in/yaml/v3 v3.0.4 // indirect
33 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect32 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
34 golang.org/x/sync v0.17.0 // indirect33 golang.org/x/sync v0.17.0 // indirect
35- golang.org/x/sys v0.36.0 // indirect
36 golang.org/x/text v0.29.0 // indirect34 golang.org/x/text v0.29.0 // indirect
37 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect35 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
38 )36 )
39 37
40-replace github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.138+replace (
39+ github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751
40+ github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f
41+ github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.2-0.20251017152300-b3012dbe285f
42+ github.com/diamondburned/gotk4/pkg => github.com/bnema/gotk4/pkg v0.3.4-0.20251017144723-f765b03fd751
43+)
M go.sum +4 -8 generated file: collapsed
M gui/src/components/omnibox/OmniboxSuggestions.svelte +6 -1
@@ -190,17 +190,22 @@
190190 .suggestion-favicon {
191191 width: 20px;
192192 height: 20px;
193- border: 1px solid var(--dynamic-border);
193+ border: none;
194+ border-radius: 3px;
194195 display: flex;
195196 align-items: center;
196197 justify-content: center;
197198 background: var(--dynamic-bg);
199+ flex-shrink: 0;
200+ overflow: hidden;
198201 }
199202
200203 .suggestion-favicon-img {
201204 width: 16px;
202205 height: 16px;
203206 object-fit: contain;
207+ image-rendering: -webkit-optimize-contrast;
208+ -ms-interpolation-mode: nearest-neighbor;
204209 }
205210
206211 .suggestion-text {
@@ -190,17 +190,22 @@
190 .suggestion-favicon {190 .suggestion-favicon {
191 width: 20px;191 width: 20px;
192 height: 20px;192 height: 20px;
193- border: 1px solid var(--dynamic-border);193+ border: none;
194+ border-radius: 3px;
194 display: flex;195 display: flex;
195 align-items: center;196 align-items: center;
196 justify-content: center;197 justify-content: center;
197 background: var(--dynamic-bg);198 background: var(--dynamic-bg);
199+ flex-shrink: 0;
200+ overflow: hidden;
198 }201 }
199 202
200 .suggestion-favicon-img {203 .suggestion-favicon-img {
201 width: 16px;204 width: 16px;
202 height: 16px;205 height: 16px;
203 object-fit: contain;206 object-fit: contain;
207+ image-rendering: -webkit-optimize-contrast;
208+ -ms-interpolation-mode: nearest-neighbor;
204 }209 }
205 210
206 .suggestion-text {211 .suggestion-text {
M internal/app/browser/browser.go +1 -0
@@ -36,6 +36,7 @@ type BrowserApp struct {
3636 // Services
3737 parserService *services.ParserService
3838 browserService *services.BrowserService
39+ faviconService *services.FaviconService
3940
4041 // WebView and controllers
4142 webView *webkit.WebView
@@ -36,6 +36,7 @@ type BrowserApp struct {
36 // Services36 // Services
37 parserService *services.ParserService37 parserService *services.ParserService
38 browserService *services.BrowserService38 browserService *services.BrowserService
39+ faviconService *services.FaviconService
39 40
40 // WebView and controllers41 // WebView and controllers
41 webView *webkit.WebView42 webView *webkit.WebView
M internal/app/browser/stacked_panes.go +33 -70
@@ -1,15 +1,14 @@
11 package browser
22
33 import (
4- "context"
54 "errors"
65 "fmt"
76 "log"
87 "sync/atomic"
98 "time"
109
11- "github.com/bnema/dumber/internal/cache"
1210 "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
1312 "github.com/diamondburned/gotk4/pkg/gtk/v4"
1413 )
1514
@@ -664,75 +663,39 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
664663 titleBox.SetHExpand(true)
665664 titleBox.SetVExpand(false)
666665
667- // Add favicon placeholder for debugging (always visible)
668- var faviconImg gtk.Widgetter
669- var debugReason string
670-
671- if pageURL == "" {
672- debugReason = "empty pageURL"
673- } else if spm.wm == nil {
674- debugReason = "nil workspace manager"
675- } else if spm.wm.app == nil {
676- debugReason = "nil app"
677- } else if spm.wm.app.queries == nil {
678- debugReason = "nil queries"
679- } else {
680- // Query database for favicon URL
681- ctx := context.Background()
682- entry, err := spm.wm.app.queries.GetHistoryEntry(ctx, pageURL)
683- if err != nil {
684- debugReason = fmt.Sprintf("DB query failed: %v", err)
685- log.Printf("[favicon] Failed to get history entry for %s: %v", pageURL, err)
686- } else if !entry.FaviconUrl.Valid || entry.FaviconUrl.String == "" {
687- debugReason = "no favicon URL in DB"
688- log.Printf("[favicon] No favicon URL in DB for %s", pageURL)
689- } else {
690- faviconURL := entry.FaviconUrl.String
691- log.Printf("[favicon] Found favicon URL in DB for %s: %s", pageURL, faviconURL)
666+ placeholderLabel := gtk.NewLabel(faviconPlaceholder)
667+ if placeholderLabel != nil {
668+ placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
669+ titleBox.Append(placeholderLabel)
670+ webkit.WidgetSetVisible(placeholderLabel, true)
671+ }
692672
693- // Use favicon cache with favicon URL (same as dmenu)
694- faviconCache, err := cache.NewFaviconCache()
673+ if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
674+ spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
695675 if err != nil {
696- debugReason = fmt.Sprintf("cache init failed: %v", err)
697- log.Printf("[favicon] Failed to create cache: %v", err)
698- } else {
699- faviconPath := faviconCache.GetCachedPath(faviconURL)
700- if faviconPath != "" {
701- log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)
702- faviconImg = gtk.NewImageFromFile(faviconPath)
703- if faviconImg != nil {
704- if img, ok := faviconImg.(*gtk.Image); ok {
705- img.SetPixelSize(16)
706- }
707- webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
708- log.Printf("[favicon] Successfully created favicon image widget")
709- } else {
710- debugReason = "image widget creation failed"
711- log.Printf("[favicon] Failed to create image widget from %s", faviconPath)
712- }
713- } else {
714- debugReason = "not cached yet"
715- log.Printf("[favicon] Favicon not cached yet, starting download: %s", faviconURL)
716- // Start async download for next time (same as dmenu)
717- faviconCache.CacheAsync(faviconURL)
718- }
676+ log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
677+ return
719678 }
720- }
721- }
722679
723- // Add favicon or placeholder
724- if faviconImg != nil {
725- titleBox.Append(faviconImg)
726- webkit.WidgetShow(faviconImg)
727- } else {
728- // DEBUG: Add visible placeholder label to show code is running
729- placeholderLabel := gtk.NewLabel(faviconPlaceholder)
730- if placeholderLabel != nil {
731- placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
732- titleBox.Append(placeholderLabel)
733- placeholderLabel.Show()
734- log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)
735- }
680+ if texture != nil && titleBox != nil {
681+ log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL)
682+
683+ // Create favicon image
684+ faviconImg := gtk.NewImageFromPaintable(texture)
685+ faviconImg.SetPixelSize(16)
686+ webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
687+
688+ // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
689+ // This handles the case where multiple async callbacks fire for the same title bar
690+ if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
691+ titleBox.Remove(placeholderLabel)
692+ }
693+
694+ // Prepend favicon image (multiple calls are safe - GTK handles it)
695+ titleBox.Prepend(faviconImg)
696+ webkit.WidgetSetVisible(faviconImg, true)
697+ }
698+ })
736699 }
737700
738701 // Create title label with the specified title
@@ -742,11 +705,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
742705 }
743706
744707 titleLabel.AddCSSClass("stacked-pane-title-text")
745- titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2
708+ titleLabel.SetEllipsize(2)
746709
747710 titleBox.Append(titleLabel)
748- titleBox.Show()
749- titleLabel.Show()
711+ webkit.WidgetSetVisible(titleBox, true)
712+ webkit.WidgetSetVisible(titleLabel, true)
750713
751714 return titleBox
752715 }
@@ -1,15 +1,14 @@
1 package browser1 package browser
2 2
3 import (3 import (
4- "context"
5 "errors"4 "errors"
6 "fmt"5 "fmt"
7 "log"6 "log"
8 "sync/atomic"7 "sync/atomic"
9 "time"8 "time"
10 9
11- "github.com/bnema/dumber/internal/cache"
12 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
13 "github.com/diamondburned/gotk4/pkg/gtk/v4"12 "github.com/diamondburned/gotk4/pkg/gtk/v4"
14 )13 )
15 14
@@ -664,75 +663,39 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
664 titleBox.SetHExpand(true)663 titleBox.SetHExpand(true)
665 titleBox.SetVExpand(false)664 titleBox.SetVExpand(false)
666 665
667- // Add favicon placeholder for debugging (always visible)666+ placeholderLabel := gtk.NewLabel(faviconPlaceholder)
668- var faviconImg gtk.Widgetter667+ if placeholderLabel != nil {
669- var debugReason string668+ placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
670-669+ titleBox.Append(placeholderLabel)
671- if pageURL == "" {670+ webkit.WidgetSetVisible(placeholderLabel, true)
672- debugReason = "empty pageURL"671+ }
673- } else if spm.wm == nil {
674- debugReason = "nil workspace manager"
675- } else if spm.wm.app == nil {
676- debugReason = "nil app"
677- } else if spm.wm.app.queries == nil {
678- debugReason = "nil queries"
679- } else {
680- // Query database for favicon URL
681- ctx := context.Background()
682- entry, err := spm.wm.app.queries.GetHistoryEntry(ctx, pageURL)
683- if err != nil {
684- debugReason = fmt.Sprintf("DB query failed: %v", err)
685- log.Printf("[favicon] Failed to get history entry for %s: %v", pageURL, err)
686- } else if !entry.FaviconUrl.Valid || entry.FaviconUrl.String == "" {
687- debugReason = "no favicon URL in DB"
688- log.Printf("[favicon] No favicon URL in DB for %s", pageURL)
689- } else {
690- faviconURL := entry.FaviconUrl.String
691- log.Printf("[favicon] Found favicon URL in DB for %s: %s", pageURL, faviconURL)
692 672
693- // Use favicon cache with favicon URL (same as dmenu)673+ if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
694- faviconCache, err := cache.NewFaviconCache()674+ spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
695 if err != nil {675 if err != nil {
696- debugReason = fmt.Sprintf("cache init failed: %v", err)676+ log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
697- log.Printf("[favicon] Failed to create cache: %v", err)677+ return
698- } else {
699- faviconPath := faviconCache.GetCachedPath(faviconURL)
700- if faviconPath != "" {
701- log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)
702- faviconImg = gtk.NewImageFromFile(faviconPath)
703- if faviconImg != nil {
704- if img, ok := faviconImg.(*gtk.Image); ok {
705- img.SetPixelSize(16)
706- }
707- webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
708- log.Printf("[favicon] Successfully created favicon image widget")
709- } else {
710- debugReason = "image widget creation failed"
711- log.Printf("[favicon] Failed to create image widget from %s", faviconPath)
712- }
713- } else {
714- debugReason = "not cached yet"
715- log.Printf("[favicon] Favicon not cached yet, starting download: %s", faviconURL)
716- // Start async download for next time (same as dmenu)
717- faviconCache.CacheAsync(faviconURL)
718- }
719 }678 }
720- }
721- }
722 679
723- // Add favicon or placeholder680+ if texture != nil && titleBox != nil {
724- if faviconImg != nil {681+ log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL)
725- titleBox.Append(faviconImg)682+
726- webkit.WidgetShow(faviconImg)683+ // Create favicon image
727- } else {684+ faviconImg := gtk.NewImageFromPaintable(texture)
728- // DEBUG: Add visible placeholder label to show code is running685+ faviconImg.SetPixelSize(16)
729- placeholderLabel := gtk.NewLabel(faviconPlaceholder)686+ webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
730- if placeholderLabel != nil {687+
731- placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")688+ // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
732- titleBox.Append(placeholderLabel)689+ // This handles the case where multiple async callbacks fire for the same title bar
733- placeholderLabel.Show()690+ if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
734- log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)691+ titleBox.Remove(placeholderLabel)
735- }692+ }
693+
694+ // Prepend favicon image (multiple calls are safe - GTK handles it)
695+ titleBox.Prepend(faviconImg)
696+ webkit.WidgetSetVisible(faviconImg, true)
697+ }
698+ })
736 }699 }
737 700
738 // Create title label with the specified title701 // Create title label with the specified title
@@ -742,11 +705,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
742 }705 }
743 706
744 titleLabel.AddCSSClass("stacked-pane-title-text")707 titleLabel.AddCSSClass("stacked-pane-title-text")
745- titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2708+ titleLabel.SetEllipsize(2)
746 709
747 titleBox.Append(titleLabel)710 titleBox.Append(titleLabel)
748- titleBox.Show()711+ webkit.WidgetSetVisible(titleBox, true)
749- titleLabel.Show()712+ webkit.WidgetSetVisible(titleLabel, true)
750 713
751 return titleBox714 return titleBox
752 }715 }
M internal/app/browser/webview.go +33 -9
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/dumber/internal/app/messaging"
1515 "github.com/bnema/dumber/internal/config"
1616 "github.com/bnema/dumber/internal/filtering"
17+ "github.com/bnema/dumber/internal/services"
1718 "github.com/bnema/dumber/pkg/webkit"
1819 )
1920
@@ -132,15 +133,6 @@ func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
132133 app.workspace.RegisterNavigationHandler(view)
133134 }
134135
135- // Register favicon handlers for this webview (needed for each pane)
136- // This ensures favicons are detected and cached for all panes, not just the main webview
137- view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
138- // Call the private handler through a public wrapper or directly via browserService
139- // We can't call handleFaviconURIChanged directly as it's private, so we need to expose it
140- // For now, duplicate the minimal logic here
141- app.browserService.ProcessFaviconURI(pageURI, faviconURI)
142- })
143-
144136 return pane, nil
145137 }
146138
@@ -287,6 +279,12 @@ func (app *BrowserApp) createWebView() error {
287279
288280 app.setupWebViewIntegration()
289281
282+ // Initialize FaviconService after WebView is created
283+ if err := app.initializeFaviconService(); err != nil {
284+ log.Printf("Warning: failed to initialize favicon service: %v", err)
285+ // Continue without favicon service - not critical
286+ }
287+
290288 // Apply initial zoom and show window
291289 app.zoomController.ApplyInitialZoom()
292290
@@ -313,6 +311,32 @@ func (app *BrowserApp) setupWebViewIntegration() {
313311 }
314312 }
315313
314+// initializeFaviconService creates and initializes the FaviconService
315+func (app *BrowserApp) initializeFaviconService() error {
316+ // Get the FaviconDatabase from the WebView
317+ faviconDB := app.webView.GetFaviconDatabase()
318+ if faviconDB == nil {
319+ return fmt.Errorf("favicon database not available")
320+ }
321+
322+ // Get the WebKit data directory
323+ dataDir, err := config.GetDataDir()
324+ if err != nil {
325+ return fmt.Errorf("failed to get data directory: %w", err)
326+ }
327+ webkitData := filepath.Join(dataDir, "webkit")
328+
329+ // Create FaviconService
330+ faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData)
331+ if err != nil {
332+ return fmt.Errorf("failed to create favicon service: %w", err)
333+ }
334+
335+ app.faviconService = faviconService
336+ log.Printf("[favicon] FaviconService initialized successfully")
337+ return nil
338+}
339+
316340 // setupContentBlocking initializes the content blocking system with proper timing
317341 func (app *BrowserApp) setupContentBlocking() error {
318342 log.Printf("Initializing content blocking system...")
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/dumber/internal/app/messaging"14 "github.com/bnema/dumber/internal/app/messaging"
15 "github.com/bnema/dumber/internal/config"15 "github.com/bnema/dumber/internal/config"
16 "github.com/bnema/dumber/internal/filtering"16 "github.com/bnema/dumber/internal/filtering"
17+ "github.com/bnema/dumber/internal/services"
17 "github.com/bnema/dumber/pkg/webkit"18 "github.com/bnema/dumber/pkg/webkit"
18 )19 )
19 20
@@ -132,15 +133,6 @@ func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
132 app.workspace.RegisterNavigationHandler(view)133 app.workspace.RegisterNavigationHandler(view)
133 }134 }
134 135
135- // Register favicon handlers for this webview (needed for each pane)
136- // This ensures favicons are detected and cached for all panes, not just the main webview
137- view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
138- // Call the private handler through a public wrapper or directly via browserService
139- // We can't call handleFaviconURIChanged directly as it's private, so we need to expose it
140- // For now, duplicate the minimal logic here
141- app.browserService.ProcessFaviconURI(pageURI, faviconURI)
142- })
143-
144 return pane, nil136 return pane, nil
145 }137 }
146 138
@@ -287,6 +279,12 @@ func (app *BrowserApp) createWebView() error {
287 279
288 app.setupWebViewIntegration()280 app.setupWebViewIntegration()
289 281
282+ // Initialize FaviconService after WebView is created
283+ if err := app.initializeFaviconService(); err != nil {
284+ log.Printf("Warning: failed to initialize favicon service: %v", err)
285+ // Continue without favicon service - not critical
286+ }
287+
290 // Apply initial zoom and show window288 // Apply initial zoom and show window
291 app.zoomController.ApplyInitialZoom()289 app.zoomController.ApplyInitialZoom()
292 290
@@ -313,6 +311,32 @@ func (app *BrowserApp) setupWebViewIntegration() {
313 }311 }
314 }312 }
315 313
314+// initializeFaviconService creates and initializes the FaviconService
315+func (app *BrowserApp) initializeFaviconService() error {
316+ // Get the FaviconDatabase from the WebView
317+ faviconDB := app.webView.GetFaviconDatabase()
318+ if faviconDB == nil {
319+ return fmt.Errorf("favicon database not available")
320+ }
321+
322+ // Get the WebKit data directory
323+ dataDir, err := config.GetDataDir()
324+ if err != nil {
325+ return fmt.Errorf("failed to get data directory: %w", err)
326+ }
327+ webkitData := filepath.Join(dataDir, "webkit")
328+
329+ // Create FaviconService
330+ faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData)
331+ if err != nil {
332+ return fmt.Errorf("failed to create favicon service: %w", err)
333+ }
334+
335+ app.faviconService = faviconService
336+ log.Printf("[favicon] FaviconService initialized successfully")
337+ return nil
338+}
339+
316 // setupContentBlocking initializes the content blocking system with proper timing340 // setupContentBlocking initializes the content blocking system with proper timing
317 func (app *BrowserApp) setupContentBlocking() error {341 func (app *BrowserApp) setupContentBlocking() error {
318 log.Printf("Initializing content blocking system...")342 log.Printf("Initializing content blocking system...")
M internal/app/browser/workspace_pane_ops.go +1 -1
@@ -908,7 +908,7 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
908908 log.Printf("[workspace] closing final pane; exiting browser")
909909 wm.cleanupPane(node, wm.nextCleanupGeneration())
910910 wm.detachHover(node)
911-
911+
912912 // Gracefully quit the GTK main loop
913913 webkit.QuitMainLoop()
914914 return nil, nil
@@ -908,7 +908,7 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
908 log.Printf("[workspace] closing final pane; exiting browser")908 log.Printf("[workspace] closing final pane; exiting browser")
909 wm.cleanupPane(node, wm.nextCleanupGeneration())909 wm.cleanupPane(node, wm.nextCleanupGeneration())
910 wm.detachHover(node)910 wm.detachHover(node)
911- 911+
912 // Gracefully quit the GTK main loop912 // Gracefully quit the GTK main loop
913 webkit.QuitMainLoop()913 webkit.QuitMainLoop()
914 return nil, nil914 return nil, nil
M internal/app/messaging/handler.go +1 -31
@@ -5,7 +5,6 @@ import (
55 "encoding/json"
66 "log"
77 "strconv"
8- "strings"
98 "time"
109
1110 "github.com/bnema/dumber/internal/app/constants"
@@ -226,33 +225,6 @@ func (h *Handler) handleQuery(msg Message) {
226225 Favicon string `json:"favicon,omitempty"`
227226 }
228227
229- buildFavicon := func(raw string) string {
230- if raw == "" {
231- return ""
232- }
233- scheme := "https"
234- host := raw
235- if i := strings.Index(raw, "://"); i >= 0 {
236- if i > 0 {
237- scheme = raw[:i]
238- }
239- rest := raw[i+3:]
240- if j := strings.IndexByte(rest, '/'); j >= 0 {
241- host = rest[:j]
242- } else {
243- host = rest
244- }
245- } else {
246- if j := strings.IndexByte(raw, '/'); j >= 0 {
247- host = raw[:j]
248- }
249- }
250- if host == "" {
251- return ""
252- }
253- return scheme + "://" + host + "/favicon.ico"
254- }
255-
256228 results := make([]suggestion, 0, limit)
257229 seen := make(map[string]struct{}, limit*2)
258230
@@ -284,12 +256,10 @@ func (h *Handler) handleQuery(msg Message) {
284256 if _, ok := seen[url]; ok {
285257 continue
286258 }
287- // Use favicon_url from database if available, otherwise build it
259+ // Use favicon_url from database (populated by FaviconService)
288260 favicon := ""
289261 if s, ok := m["favicon_url"].(string); ok && s != "" {
290262 favicon = s
291- } else {
292- favicon = buildFavicon(url)
293263 }
294264 results = append(results, suggestion{URL: url, Favicon: favicon})
295265 seen[url] = struct{}{}
@@ -5,7 +5,6 @@ import (
5 "encoding/json"5 "encoding/json"
6 "log"6 "log"
7 "strconv"7 "strconv"
8- "strings"
9 "time"8 "time"
10 9
11 "github.com/bnema/dumber/internal/app/constants"10 "github.com/bnema/dumber/internal/app/constants"
@@ -226,33 +225,6 @@ func (h *Handler) handleQuery(msg Message) {
226 Favicon string `json:"favicon,omitempty"`225 Favicon string `json:"favicon,omitempty"`
227 }226 }
228 227
229- buildFavicon := func(raw string) string {
230- if raw == "" {
231- return ""
232- }
233- scheme := "https"
234- host := raw
235- if i := strings.Index(raw, "://"); i >= 0 {
236- if i > 0 {
237- scheme = raw[:i]
238- }
239- rest := raw[i+3:]
240- if j := strings.IndexByte(rest, '/'); j >= 0 {
241- host = rest[:j]
242- } else {
243- host = rest
244- }
245- } else {
246- if j := strings.IndexByte(raw, '/'); j >= 0 {
247- host = raw[:j]
248- }
249- }
250- if host == "" {
251- return ""
252- }
253- return scheme + "://" + host + "/favicon.ico"
254- }
255-
256 results := make([]suggestion, 0, limit)228 results := make([]suggestion, 0, limit)
257 seen := make(map[string]struct{}, limit*2)229 seen := make(map[string]struct{}, limit*2)
258 230
@@ -284,12 +256,10 @@ func (h *Handler) handleQuery(msg Message) {
284 if _, ok := seen[url]; ok {256 if _, ok := seen[url]; ok {
285 continue257 continue
286 }258 }
287- // Use favicon_url from database if available, otherwise build it259+ // Use favicon_url from database (populated by FaviconService)
288 favicon := ""260 favicon := ""
289 if s, ok := m["favicon_url"].(string); ok && s != "" {261 if s, ok := m["favicon_url"].(string); ok && s != "" {
290 favicon = s262 favicon = s
291- } else {
292- favicon = buildFavicon(url)
293 }263 }
294 results = append(results, suggestion{URL: url, Favicon: favicon})264 results = append(results, suggestion{URL: url, Favicon: favicon})
295 seen[url] = struct{}{}265 seen[url] = struct{}{}
D internal/cache/favicon.go +0 -472
@@ -1,472 +0,0 @@
1-package cache
2-
3-import (
4- "crypto/md5"
5- "fmt"
6- "image"
7- "image/color"
8- _ "image/gif" // Register GIF decoder
9- _ "image/jpeg" // Register JPEG decoder
10- "image/png"
11- "io"
12- "log"
13- "net/http"
14- "os"
15- "path/filepath"
16- "strings"
17- "sync"
18- "time"
19-
20- "github.com/bnema/dumber/internal/config"
21- "github.com/sergeymakinen/go-ico"
22-)
23-
24-// FaviconCache manages favicon caching to local files
25-type FaviconCache struct {
26- cacheDir string
27- downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon
28-}
29-
30-// NewFaviconCache creates a new favicon cache
31-func NewFaviconCache() (*FaviconCache, error) {
32- dataDir, err := config.GetDataDir()
33- if err != nil {
34- return nil, fmt.Errorf("failed to get data directory: %w", err)
35- }
36-
37- cacheDir := filepath.Join(dataDir, "favicons")
38- if err := os.MkdirAll(cacheDir, 0755); err != nil {
39- return nil, fmt.Errorf("failed to create favicon cache directory: %w", err)
40- }
41-
42- return &FaviconCache{
43- cacheDir: cacheDir,
44- }, nil
45-}
46-
47-// GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise
48-func (fc *FaviconCache) GetCachedPath(faviconURL string) string {
49- if faviconURL == "" {
50- return ""
51- }
52-
53- filename := fc.getFilename(faviconURL)
54- cachedPath := filepath.Join(fc.cacheDir, filename)
55-
56- // Check if file exists and is not too old (7 days)
57- if info, err := os.Stat(cachedPath); err == nil {
58- if time.Since(info.ModTime()) < 7*24*time.Hour {
59- return cachedPath
60- }
61- }
62-
63- return ""
64-}
65-
66-// CacheAsync downloads and caches a favicon asynchronously
67-func (fc *FaviconCache) CacheAsync(faviconURL string) {
68- if faviconURL == "" {
69- return
70- }
71-
72- go func() {
73- if err := fc.downloadFavicon(faviconURL); err != nil {
74- // Silently ignore errors in background download
75- }
76- }()
77-}
78-
79-// CachePNGData caches PNG favicon data directly for a URL
80-func (fc *FaviconCache) CachePNGData(pageURL string, pngData []byte) error {
81- if pageURL == "" || len(pngData) == 0 {
82- return fmt.Errorf("invalid input: empty URL or data")
83- }
84-
85- filename := fc.getFilename(pageURL)
86- cachedPath := filepath.Join(fc.cacheDir, filename)
87-
88- // Write PNG data directly to cache
89- return os.WriteFile(cachedPath, pngData, 0644)
90-}
91-
92-// GetCachedPathForURL returns the cached path for a page URL (not favicon URL)
93-func (fc *FaviconCache) GetCachedPathForURL(pageURL string) string {
94- if pageURL == "" {
95- return ""
96- }
97-
98- filename := fc.getFilename(pageURL)
99- cachedPath := filepath.Join(fc.cacheDir, filename)
100-
101- // Check if file exists and is not too old (7 days)
102- if info, err := os.Stat(cachedPath); err == nil {
103- if time.Since(info.ModTime()) < 7*24*time.Hour {
104- return cachedPath
105- }
106- }
107-
108- return ""
109-}
110-
111-// getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility
112-func (fc *FaviconCache) getFilename(faviconURL string) string {
113- hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL)))
114- return hash + ".png"
115-}
116-
117-// downloadFavicon downloads a favicon and saves it to cache
118-func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
119- // Skip SVG files - we can't convert them to PNG without additional dependencies
120- if strings.HasSuffix(strings.ToLower(faviconURL), ".svg") {
121- log.Printf("[favicon] Skipping SVG favicon: %s", faviconURL)
122- return nil
123- }
124-
125- filename := fc.getFilename(faviconURL)
126- cachedPath := filepath.Join(fc.cacheDir, filename)
127-
128- // Lock to prevent concurrent downloads of the same favicon
129- // This prevents race conditions on temp files and duplicate downloads
130- fc.downloadMutex.Lock()
131- defer fc.downloadMutex.Unlock()
132-
133- // Double-check if already exists after acquiring lock
134- // Another goroutine might have downloaded it while we were waiting
135- if info, err := os.Stat(cachedPath); err == nil {
136- if time.Since(info.ModTime()) < 7*24*time.Hour {
137- return nil
138- }
139- }
140-
141- // Create HTTP client with timeout and redirect handling
142- client := &http.Client{
143- Timeout: 10 * time.Second,
144- CheckRedirect: func(req *http.Request, via []*http.Request) error {
145- if len(via) >= 5 {
146- return fmt.Errorf("too many redirects")
147- }
148- return nil
149- },
150- }
151-
152- resp, err := client.Get(faviconURL)
153- if err != nil {
154- return fmt.Errorf("failed to download favicon: %w", err)
155- }
156- defer func() {
157- if err := resp.Body.Close(); err != nil {
158- log.Printf("[favicon] failed to close response body: %v", err)
159- }
160- }()
161-
162- // Log redirects for debugging
163- if resp.Request.URL.String() != faviconURL {
164- log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String())
165- }
166-
167- if resp.StatusCode != http.StatusOK {
168- return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
169- }
170-
171- // Limit response body size
172- limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
173-
174- // Download to temporary file first
175- tempDownloadFile := cachedPath + ".download"
176- downloadFile, err := os.Create(tempDownloadFile)
177- if err != nil {
178- return fmt.Errorf("failed to create download temp file: %w", err)
179- }
180- defer func() {
181- if err := downloadFile.Close(); err != nil {
182- log.Printf("[favicon] failed to close download file: %v", err)
183- }
184- }()
185- defer func() {
186- // Clean up temp download file if it still exists
187- if _, err := os.Stat(tempDownloadFile); err == nil {
188- if err := os.Remove(tempDownloadFile); err != nil {
189- log.Printf("[favicon] failed to remove temp download file: %v", err)
190- }
191- }
192- }()
193-
194- // Save raw favicon data
195- if _, err := io.Copy(downloadFile, limitedBody); err != nil {
196- return fmt.Errorf("failed to save favicon data: %w", err)
197- }
198- // Note: downloadFile will be closed by the defer above, no need to close explicitly
199-
200- // Detect if it's an ICO file and convert with ffmpeg if needed
201- if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
202- return fmt.Errorf("failed to convert favicon: %w", err)
203- }
204-
205- return nil
206-}
207-
208-// convertToPNG converts a favicon file to PNG format
209-func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error {
210- // Try standard Go image decoder first for all formats (PNG, JPEG, GIF)
211- if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
212- log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
213-
214- // Apply dark theme improvements
215- img = invertIfTooDark(img)
216-
217- // Save as PNG
218- return fc.savePNG(img, outputPath)
219- }
220-
221- // Standard decode failed, try go-ico decoder for ICO files
222- log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
223- return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
224-}
225-
226-// tryStandardImageDecode attempts to decode image with Go standard library
227-func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) {
228- file, err := os.Open(filePath)
229- if err != nil {
230- return nil, err
231- }
232- defer func() {
233- if err := file.Close(); err != nil {
234- log.Printf("[favicon] failed to close file: %v", err)
235- }
236- }()
237-
238- img, _, err := image.Decode(file)
239- return img, err
240-}
241-
242-// savePNG saves an image as PNG
243-func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
244- tempFile := outputPath + ".tmp"
245- file, err := os.Create(tempFile)
246- if err != nil {
247- return fmt.Errorf("failed to create temp file: %w", err)
248- }
249-
250- // Track if we've already closed the file
251- closed := false
252- defer func() {
253- if !closed {
254- if err := file.Close(); err != nil {
255- log.Printf("[favicon] failed to close file: %v", err)
256- }
257- }
258- }()
259-
260- if err := png.Encode(file, img); err != nil {
261- if removeErr := os.Remove(tempFile); removeErr != nil {
262- log.Printf("[favicon] failed to remove temp file: %v", removeErr)
263- }
264- return fmt.Errorf("failed to encode PNG: %w", err)
265- }
266-
267- // Close the file before renaming (required on Windows, good practice on all platforms)
268- if err := file.Close(); err != nil {
269- return fmt.Errorf("failed to close file before rename: %w", err)
270- }
271- closed = true
272-
273- // Atomic move
274- if err := os.Rename(tempFile, outputPath); err != nil {
275- os.Remove(tempFile)
276- return fmt.Errorf("failed to move PNG to cache: %w", err)
277- }
278-
279- return nil
280-}
281-
282-// convertICOWithGoICO converts ICO files to PNG using go-ico library
283-func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error {
284- // Open the ICO file
285- file, err := os.Open(inputPath)
286- if err != nil {
287- return fmt.Errorf("failed to open ICO file: %w", err)
288- }
289- defer file.Close()
290-
291- // Decode ICO file
292- img, err := ico.Decode(file)
293- if err != nil {
294- return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
295- }
296-
297- log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
298-
299- // Apply dark theme improvements
300- img = invertIfTooDark(img)
301-
302- // Save as PNG
303- return fc.savePNG(img, outputPath)
304-}
305-
306-// CleanOld removes cached favicons older than 30 days
307-func (fc *FaviconCache) CleanOld() error {
308- entries, err := os.ReadDir(fc.cacheDir)
309- if err != nil {
310- return err
311- }
312-
313- cutoff := time.Now().Add(-30 * 24 * time.Hour)
314-
315- for _, entry := range entries {
316- if entry.IsDir() {
317- continue
318- }
319-
320- info, err := entry.Info()
321- if err != nil {
322- continue
323- }
324-
325- if info.ModTime().Before(cutoff) {
326- os.Remove(filepath.Join(fc.cacheDir, entry.Name()))
327- }
328- }
329-
330- return nil
331-}
332-
333-// invertIfTooDark inverts icons that are too dark for dark theme visibility
334-// Only inverts truly monochrome dark icons, preserves colorful icons
335-func invertIfTooDark(img image.Image) image.Image {
336- bounds := img.Bounds()
337-
338- // Sample pixels to calculate statistics
339- var totalR, totalG, totalB uint32
340- var pixelCount uint32
341- var colorVariance uint32
342- var transparentPixels uint32
343-
344- // Sample every 4th pixel for performance
345- for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
346- for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
347- r, g, b, a := img.At(x, y).RGBA()
348-
349- if a == 0 {
350- transparentPixels++
351- continue
352- }
353-
354- if a > 0 { // Only count non-transparent pixels
355- totalR += r
356- totalG += g
357- totalB += b
358- pixelCount++
359-
360- // Calculate color variance to detect colorful vs monochrome icons
361- // High variance = colorful (don't invert), low variance = monochrome
362- rNorm := r >> 8 // Convert to 0-255 range
363- gNorm := g >> 8
364- bNorm := b >> 8
365-
366- // Simple color variance: difference between max and min color channels
367- maxColor := rNorm
368- if gNorm > maxColor {
369- maxColor = gNorm
370- }
371- if bNorm > maxColor {
372- maxColor = bNorm
373- }
374-
375- minColor := rNorm
376- if gNorm < minColor {
377- minColor = gNorm
378- }
379- if bNorm < minColor {
380- minColor = bNorm
381- }
382-
383- colorVariance += maxColor - minColor
384- }
385- }
386- }
387-
388- if pixelCount == 0 {
389- return img // All transparent
390- }
391-
392- // Calculate average brightness (0-65535 range)
393- avgR := totalR / pixelCount
394- avgG := totalG / pixelCount
395- avgB := totalB / pixelCount
396-
397- // Calculate perceived brightness using standard luminance formula
398- brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
399-
400- // Calculate average color variance (0-255 range)
401- avgColorVariance := colorVariance / pixelCount
402-
403- // Calculate transparency ratio
404- totalPixels := pixelCount + transparentPixels
405- transparencyRatio := float64(transparentPixels) / float64(totalPixels)
406-
407- // Only invert if:
408- // 1. Icon is very dark (brightness < 20000, low brightness)
409- // 2. AND icon is mostly monochrome (low color variance < 20)
410- // 3. AND icon is not mostly transparent (< 80% transparent)
411- // This makes dark monochrome icons visible on dark themes while preserving colorful icons
412-
413- shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
414-
415- log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
416- brightness, avgColorVariance, transparencyRatio, shouldInvert)
417-
418- if shouldInvert {
419- log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
420- return invertImage(img)
421- }
422-
423- return img
424-}
425-
426-// invertImage inverts the colors of an image with edge smoothing
427-func invertImage(img image.Image) image.Image {
428- bounds := img.Bounds()
429- inverted := image.NewRGBA(bounds)
430-
431- for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
432- for x := bounds.Min.X; x < bounds.Max.X; x++ {
433- r, g, b, a := img.At(x, y).RGBA()
434-
435- // Skip fully transparent pixels to avoid noise
436- if a == 0 {
437- inverted.Set(x, y, color.RGBA{A: 0})
438- continue
439- }
440-
441- // Convert to 8-bit for easier processing
442- r8 := uint8(r >> 8)
443- g8 := uint8(g >> 8)
444- b8 := uint8(b >> 8)
445- a8 := uint8(a >> 8)
446-
447- // Invert RGB but preserve alpha
448- newR := 255 - r8
449- newG := 255 - g8
450- newB := 255 - b8
451-
452- // For semi-transparent pixels, blend the inversion more gently
453- // This reduces edge noise by making transitions smoother
454- if a8 < 255 {
455- // Reduce inversion intensity for semi-transparent pixels
456- blendFactor := float64(a8) / 255.0
457- newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor))
458- newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
459- newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
460- }
461-
462- inverted.Set(x, y, color.RGBA{
463- R: newR,
464- G: newG,
465- B: newB,
466- A: a8,
467- })
468- }
469- }
470-
471- return inverted
472-}
@@ -1,472 +0,0 @@
1-package cache
2-
3-import (
4- "crypto/md5"
5- "fmt"
6- "image"
7- "image/color"
8- _ "image/gif" // Register GIF decoder
9- _ "image/jpeg" // Register JPEG decoder
10- "image/png"
11- "io"
12- "log"
13- "net/http"
14- "os"
15- "path/filepath"
16- "strings"
17- "sync"
18- "time"
19-
20- "github.com/bnema/dumber/internal/config"
21- "github.com/sergeymakinen/go-ico"
22-)
23-
24-// FaviconCache manages favicon caching to local files
25-type FaviconCache struct {
26- cacheDir string
27- downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon
28-}
29-
30-// NewFaviconCache creates a new favicon cache
31-func NewFaviconCache() (*FaviconCache, error) {
32- dataDir, err := config.GetDataDir()
33- if err != nil {
34- return nil, fmt.Errorf("failed to get data directory: %w", err)
35- }
36-
37- cacheDir := filepath.Join(dataDir, "favicons")
38- if err := os.MkdirAll(cacheDir, 0755); err != nil {
39- return nil, fmt.Errorf("failed to create favicon cache directory: %w", err)
40- }
41-
42- return &FaviconCache{
43- cacheDir: cacheDir,
44- }, nil
45-}
46-
47-// GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise
48-func (fc *FaviconCache) GetCachedPath(faviconURL string) string {
49- if faviconURL == "" {
50- return ""
51- }
52-
53- filename := fc.getFilename(faviconURL)
54- cachedPath := filepath.Join(fc.cacheDir, filename)
55-
56- // Check if file exists and is not too old (7 days)
57- if info, err := os.Stat(cachedPath); err == nil {
58- if time.Since(info.ModTime()) < 7*24*time.Hour {
59- return cachedPath
60- }
61- }
62-
63- return ""
64-}
65-
66-// CacheAsync downloads and caches a favicon asynchronously
67-func (fc *FaviconCache) CacheAsync(faviconURL string) {
68- if faviconURL == "" {
69- return
70- }
71-
72- go func() {
73- if err := fc.downloadFavicon(faviconURL); err != nil {
74- // Silently ignore errors in background download
75- }
76- }()
77-}
78-
79-// CachePNGData caches PNG favicon data directly for a URL
80-func (fc *FaviconCache) CachePNGData(pageURL string, pngData []byte) error {
81- if pageURL == "" || len(pngData) == 0 {
82- return fmt.Errorf("invalid input: empty URL or data")
83- }
84-
85- filename := fc.getFilename(pageURL)
86- cachedPath := filepath.Join(fc.cacheDir, filename)
87-
88- // Write PNG data directly to cache
89- return os.WriteFile(cachedPath, pngData, 0644)
90-}
91-
92-// GetCachedPathForURL returns the cached path for a page URL (not favicon URL)
93-func (fc *FaviconCache) GetCachedPathForURL(pageURL string) string {
94- if pageURL == "" {
95- return ""
96- }
97-
98- filename := fc.getFilename(pageURL)
99- cachedPath := filepath.Join(fc.cacheDir, filename)
100-
101- // Check if file exists and is not too old (7 days)
102- if info, err := os.Stat(cachedPath); err == nil {
103- if time.Since(info.ModTime()) < 7*24*time.Hour {
104- return cachedPath
105- }
106- }
107-
108- return ""
109-}
110-
111-// getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility
112-func (fc *FaviconCache) getFilename(faviconURL string) string {
113- hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL)))
114- return hash + ".png"
115-}
116-
117-// downloadFavicon downloads a favicon and saves it to cache
118-func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
119- // Skip SVG files - we can't convert them to PNG without additional dependencies
120- if strings.HasSuffix(strings.ToLower(faviconURL), ".svg") {
121- log.Printf("[favicon] Skipping SVG favicon: %s", faviconURL)
122- return nil
123- }
124-
125- filename := fc.getFilename(faviconURL)
126- cachedPath := filepath.Join(fc.cacheDir, filename)
127-
128- // Lock to prevent concurrent downloads of the same favicon
129- // This prevents race conditions on temp files and duplicate downloads
130- fc.downloadMutex.Lock()
131- defer fc.downloadMutex.Unlock()
132-
133- // Double-check if already exists after acquiring lock
134- // Another goroutine might have downloaded it while we were waiting
135- if info, err := os.Stat(cachedPath); err == nil {
136- if time.Since(info.ModTime()) < 7*24*time.Hour {
137- return nil
138- }
139- }
140-
141- // Create HTTP client with timeout and redirect handling
142- client := &http.Client{
143- Timeout: 10 * time.Second,
144- CheckRedirect: func(req *http.Request, via []*http.Request) error {
145- if len(via) >= 5 {
146- return fmt.Errorf("too many redirects")
147- }
148- return nil
149- },
150- }
151-
152- resp, err := client.Get(faviconURL)
153- if err != nil {
154- return fmt.Errorf("failed to download favicon: %w", err)
155- }
156- defer func() {
157- if err := resp.Body.Close(); err != nil {
158- log.Printf("[favicon] failed to close response body: %v", err)
159- }
160- }()
161-
162- // Log redirects for debugging
163- if resp.Request.URL.String() != faviconURL {
164- log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String())
165- }
166-
167- if resp.StatusCode != http.StatusOK {
168- return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
169- }
170-
171- // Limit response body size
172- limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
173-
174- // Download to temporary file first
175- tempDownloadFile := cachedPath + ".download"
176- downloadFile, err := os.Create(tempDownloadFile)
177- if err != nil {
178- return fmt.Errorf("failed to create download temp file: %w", err)
179- }
180- defer func() {
181- if err := downloadFile.Close(); err != nil {
182- log.Printf("[favicon] failed to close download file: %v", err)
183- }
184- }()
185- defer func() {
186- // Clean up temp download file if it still exists
187- if _, err := os.Stat(tempDownloadFile); err == nil {
188- if err := os.Remove(tempDownloadFile); err != nil {
189- log.Printf("[favicon] failed to remove temp download file: %v", err)
190- }
191- }
192- }()
193-
194- // Save raw favicon data
195- if _, err := io.Copy(downloadFile, limitedBody); err != nil {
196- return fmt.Errorf("failed to save favicon data: %w", err)
197- }
198- // Note: downloadFile will be closed by the defer above, no need to close explicitly
199-
200- // Detect if it's an ICO file and convert with ffmpeg if needed
201- if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
202- return fmt.Errorf("failed to convert favicon: %w", err)
203- }
204-
205- return nil
206-}
207-
208-// convertToPNG converts a favicon file to PNG format
209-func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error {
210- // Try standard Go image decoder first for all formats (PNG, JPEG, GIF)
211- if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
212- log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
213-
214- // Apply dark theme improvements
215- img = invertIfTooDark(img)
216-
217- // Save as PNG
218- return fc.savePNG(img, outputPath)
219- }
220-
221- // Standard decode failed, try go-ico decoder for ICO files
222- log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
223- return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
224-}
225-
226-// tryStandardImageDecode attempts to decode image with Go standard library
227-func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) {
228- file, err := os.Open(filePath)
229- if err != nil {
230- return nil, err
231- }
232- defer func() {
233- if err := file.Close(); err != nil {
234- log.Printf("[favicon] failed to close file: %v", err)
235- }
236- }()
237-
238- img, _, err := image.Decode(file)
239- return img, err
240-}
241-
242-// savePNG saves an image as PNG
243-func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
244- tempFile := outputPath + ".tmp"
245- file, err := os.Create(tempFile)
246- if err != nil {
247- return fmt.Errorf("failed to create temp file: %w", err)
248- }
249-
250- // Track if we've already closed the file
251- closed := false
252- defer func() {
253- if !closed {
254- if err := file.Close(); err != nil {
255- log.Printf("[favicon] failed to close file: %v", err)
256- }
257- }
258- }()
259-
260- if err := png.Encode(file, img); err != nil {
261- if removeErr := os.Remove(tempFile); removeErr != nil {
262- log.Printf("[favicon] failed to remove temp file: %v", removeErr)
263- }
264- return fmt.Errorf("failed to encode PNG: %w", err)
265- }
266-
267- // Close the file before renaming (required on Windows, good practice on all platforms)
268- if err := file.Close(); err != nil {
269- return fmt.Errorf("failed to close file before rename: %w", err)
270- }
271- closed = true
272-
273- // Atomic move
274- if err := os.Rename(tempFile, outputPath); err != nil {
275- os.Remove(tempFile)
276- return fmt.Errorf("failed to move PNG to cache: %w", err)
277- }
278-
279- return nil
280-}
281-
282-// convertICOWithGoICO converts ICO files to PNG using go-ico library
283-func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error {
284- // Open the ICO file
285- file, err := os.Open(inputPath)
286- if err != nil {
287- return fmt.Errorf("failed to open ICO file: %w", err)
288- }
289- defer file.Close()
290-
291- // Decode ICO file
292- img, err := ico.Decode(file)
293- if err != nil {
294- return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
295- }
296-
297- log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
298-
299- // Apply dark theme improvements
300- img = invertIfTooDark(img)
301-
302- // Save as PNG
303- return fc.savePNG(img, outputPath)
304-}
305-
306-// CleanOld removes cached favicons older than 30 days
307-func (fc *FaviconCache) CleanOld() error {
308- entries, err := os.ReadDir(fc.cacheDir)
309- if err != nil {
310- return err
311- }
312-
313- cutoff := time.Now().Add(-30 * 24 * time.Hour)
314-
315- for _, entry := range entries {
316- if entry.IsDir() {
317- continue
318- }
319-
320- info, err := entry.Info()
321- if err != nil {
322- continue
323- }
324-
325- if info.ModTime().Before(cutoff) {
326- os.Remove(filepath.Join(fc.cacheDir, entry.Name()))
327- }
328- }
329-
330- return nil
331-}
332-
333-// invertIfTooDark inverts icons that are too dark for dark theme visibility
334-// Only inverts truly monochrome dark icons, preserves colorful icons
335-func invertIfTooDark(img image.Image) image.Image {
336- bounds := img.Bounds()
337-
338- // Sample pixels to calculate statistics
339- var totalR, totalG, totalB uint32
340- var pixelCount uint32
341- var colorVariance uint32
342- var transparentPixels uint32
343-
344- // Sample every 4th pixel for performance
345- for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
346- for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
347- r, g, b, a := img.At(x, y).RGBA()
348-
349- if a == 0 {
350- transparentPixels++
351- continue
352- }
353-
354- if a > 0 { // Only count non-transparent pixels
355- totalR += r
356- totalG += g
357- totalB += b
358- pixelCount++
359-
360- // Calculate color variance to detect colorful vs monochrome icons
361- // High variance = colorful (don't invert), low variance = monochrome
362- rNorm := r >> 8 // Convert to 0-255 range
363- gNorm := g >> 8
364- bNorm := b >> 8
365-
366- // Simple color variance: difference between max and min color channels
367- maxColor := rNorm
368- if gNorm > maxColor {
369- maxColor = gNorm
370- }
371- if bNorm > maxColor {
372- maxColor = bNorm
373- }
374-
375- minColor := rNorm
376- if gNorm < minColor {
377- minColor = gNorm
378- }
379- if bNorm < minColor {
380- minColor = bNorm
381- }
382-
383- colorVariance += maxColor - minColor
384- }
385- }
386- }
387-
388- if pixelCount == 0 {
389- return img // All transparent
390- }
391-
392- // Calculate average brightness (0-65535 range)
393- avgR := totalR / pixelCount
394- avgG := totalG / pixelCount
395- avgB := totalB / pixelCount
396-
397- // Calculate perceived brightness using standard luminance formula
398- brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
399-
400- // Calculate average color variance (0-255 range)
401- avgColorVariance := colorVariance / pixelCount
402-
403- // Calculate transparency ratio
404- totalPixels := pixelCount + transparentPixels
405- transparencyRatio := float64(transparentPixels) / float64(totalPixels)
406-
407- // Only invert if:
408- // 1. Icon is very dark (brightness < 20000, low brightness)
409- // 2. AND icon is mostly monochrome (low color variance < 20)
410- // 3. AND icon is not mostly transparent (< 80% transparent)
411- // This makes dark monochrome icons visible on dark themes while preserving colorful icons
412-
413- shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
414-
415- log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
416- brightness, avgColorVariance, transparencyRatio, shouldInvert)
417-
418- if shouldInvert {
419- log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
420- return invertImage(img)
421- }
422-
423- return img
424-}
425-
426-// invertImage inverts the colors of an image with edge smoothing
427-func invertImage(img image.Image) image.Image {
428- bounds := img.Bounds()
429- inverted := image.NewRGBA(bounds)
430-
431- for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
432- for x := bounds.Min.X; x < bounds.Max.X; x++ {
433- r, g, b, a := img.At(x, y).RGBA()
434-
435- // Skip fully transparent pixels to avoid noise
436- if a == 0 {
437- inverted.Set(x, y, color.RGBA{A: 0})
438- continue
439- }
440-
441- // Convert to 8-bit for easier processing
442- r8 := uint8(r >> 8)
443- g8 := uint8(g >> 8)
444- b8 := uint8(b >> 8)
445- a8 := uint8(a >> 8)
446-
447- // Invert RGB but preserve alpha
448- newR := 255 - r8
449- newG := 255 - g8
450- newB := 255 - b8
451-
452- // For semi-transparent pixels, blend the inversion more gently
453- // This reduces edge noise by making transitions smoother
454- if a8 < 255 {
455- // Reduce inversion intensity for semi-transparent pixels
456- blendFactor := float64(a8) / 255.0
457- newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor))
458- newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
459- newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
460- }
461-
462- inverted.Set(x, y, color.RGBA{
463- R: newR,
464- G: newG,
465- B: newB,
466- A: a8,
467- })
468- }
469- }
470-
471- return inverted
472-}
M internal/cli/browse.go +0 -7
@@ -9,7 +9,6 @@ import (
99 "os/exec"
1010 "strings"
1111
12- "github.com/bnema/dumber/internal/cache"
1312 "github.com/bnema/dumber/internal/config"
1413
1514 "github.com/spf13/cobra"
@@ -161,12 +160,6 @@ func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
161160 // Update in database using the new sqlc-generated method
162161 faviconNullString := sql.NullString{String: faviconURL, Valid: true}
163162 if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
164- // Silently fail - favicon is not critical
165163 return
166164 }
167-
168- // Also cache the favicon for dmenu use
169- if faviconCache, err := cache.NewFaviconCache(); err == nil {
170- faviconCache.CacheAsync(faviconURL)
171- }
172165 }
@@ -9,7 +9,6 @@ import (
9 "os/exec"9 "os/exec"
10 "strings"10 "strings"
11 11
12- "github.com/bnema/dumber/internal/cache"
13 "github.com/bnema/dumber/internal/config"12 "github.com/bnema/dumber/internal/config"
14 13
15 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
@@ -161,12 +160,6 @@ func updateFavicon(ctx context.Context, cli *CLI, pageURL string) {
161 // Update in database using the new sqlc-generated method160 // Update in database using the new sqlc-generated method
162 faviconNullString := sql.NullString{String: faviconURL, Valid: true}161 faviconNullString := sql.NullString{String: faviconURL, Valid: true}
163 if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {162 if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
164- // Silently fail - favicon is not critical
165 return163 return
166 }164 }
167-
168- // Also cache the favicon for dmenu use
169- if faviconCache, err := cache.NewFaviconCache(); err == nil {
170- faviconCache.CacheAsync(faviconURL)
171- }
172 }165 }
M internal/cli/dmenu.go +21 -18
@@ -8,8 +8,11 @@ import (
88 "os"
99 "sort"
1010 "strings"
11+ "time"
1112
1213 "github.com/bnema/dumber/internal/cache"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/services"
1316 "github.com/spf13/cobra"
1417 )
1518
@@ -63,11 +66,10 @@ func generateOptions(cli *CLI) error {
6366 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
6467 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
6568
66- // Initialize favicon cache
67- faviconCache, err := cache.NewFaviconCache()
69+ // Get data directory for favicon exports
70+ dataDir, err := config.GetDataDir()
6871 if err != nil {
69- // Continue without favicon cache if it fails
70- faviconCache = nil
72+ dataDir = "" // Continue without favicons if data dir fails
7173 }
7274
7375 // Get top entries from cache (this is blazingly fast!)
@@ -139,7 +141,7 @@ func generateOptions(cli *CLI) error {
139141
140142 // Output options to stdout with icon specifications
141143 for _, option := range options {
142- iconName := getIconName(option, faviconCache)
144+ iconName := getIconName(option, dataDir)
143145 if iconName != "" {
144146 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
145147 } else {
@@ -157,11 +159,10 @@ func generateOptions(cli *CLI) error {
157159 func generateOptionsFallback(cli *CLI) error {
158160 ctx := context.Background()
159161
160- // Initialize favicon cache
161- faviconCache, err := cache.NewFaviconCache()
162+ // Get data directory for favicon exports
163+ dataDir, err := config.GetDataDir()
162164 if err != nil {
163- // Continue without favicon cache if it fails
164- faviconCache = nil
165+ dataDir = "" // Continue without favicons if data dir fails
165166 }
166167
167168 const maxHistoryEntries = 50
@@ -240,7 +241,7 @@ func generateOptionsFallback(cli *CLI) error {
240241
241242 // Output options to stdout with icon specifications
242243 for _, option := range options {
243- iconName := getIconName(option, faviconCache)
244+ iconName := getIconName(option, dataDir)
244245 if iconName != "" {
245246 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
246247 } else {
@@ -344,19 +345,21 @@ func parseSelection(selection string) string {
344345 }
345346
346347 // getIconName determines the appropriate icon name for a dmenu option
347-func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {
348+func getIconName(option DmenuOption, dataDir string) string {
348349 if option.Type != historyType {
349350 return ""
350351 }
351352
352- // Only use cached favicons - no system theme fallbacks for consistent sizing
353- if option.FaviconURL != "" && faviconCache != nil {
354- cachedPath := faviconCache.GetCachedPath(option.FaviconURL)
355- if cachedPath != "" {
356- return cachedPath
353+ // Get exported favicon path from FaviconService
354+ // FaviconService exports all favicons as 32x32 PNG files for CLI access
355+ if option.Value != "" {
356+ faviconPath := services.GetExportedFaviconPath(dataDir, option.Value)
357+ // Check if file exists and is recent (within 7 days)
358+ if info, err := os.Stat(faviconPath); err == nil {
359+ if time.Since(info.ModTime()) < 7*24*time.Hour {
360+ return faviconPath
361+ }
357362 }
358- // Start async download for next time
359- faviconCache.CacheAsync(option.FaviconURL)
360363 }
361364
362365 // No favicon available - return empty string to show no icon
@@ -8,8 +8,11 @@ import (
8 "os"8 "os"
9 "sort"9 "sort"
10 "strings"10 "strings"
11+ "time"
11 12
12 "github.com/bnema/dumber/internal/cache"13 "github.com/bnema/dumber/internal/cache"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/services"
13 "github.com/spf13/cobra"16 "github.com/spf13/cobra"
14 )17 )
15 18
@@ -63,11 +66,10 @@ func generateOptions(cli *CLI) error {
63 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries66 cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries
64 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)67 cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig)
65 68
66- // Initialize favicon cache69+ // Get data directory for favicon exports
67- faviconCache, err := cache.NewFaviconCache()70+ dataDir, err := config.GetDataDir()
68 if err != nil {71 if err != nil {
69- // Continue without favicon cache if it fails72+ dataDir = "" // Continue without favicons if data dir fails
70- faviconCache = nil
71 }73 }
72 74
73 // Get top entries from cache (this is blazingly fast!)75 // Get top entries from cache (this is blazingly fast!)
@@ -139,7 +141,7 @@ func generateOptions(cli *CLI) error {
139 141
140 // Output options to stdout with icon specifications142 // Output options to stdout with icon specifications
141 for _, option := range options {143 for _, option := range options {
142- iconName := getIconName(option, faviconCache)144+ iconName := getIconName(option, dataDir)
143 if iconName != "" {145 if iconName != "" {
144 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)146 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
145 } else {147 } else {
@@ -157,11 +159,10 @@ func generateOptions(cli *CLI) error {
157 func generateOptionsFallback(cli *CLI) error {159 func generateOptionsFallback(cli *CLI) error {
158 ctx := context.Background()160 ctx := context.Background()
159 161
160- // Initialize favicon cache162+ // Get data directory for favicon exports
161- faviconCache, err := cache.NewFaviconCache()163+ dataDir, err := config.GetDataDir()
162 if err != nil {164 if err != nil {
163- // Continue without favicon cache if it fails165+ dataDir = "" // Continue without favicons if data dir fails
164- faviconCache = nil
165 }166 }
166 167
167 const maxHistoryEntries = 50168 const maxHistoryEntries = 50
@@ -240,7 +241,7 @@ func generateOptionsFallback(cli *CLI) error {
240 241
241 // Output options to stdout with icon specifications242 // Output options to stdout with icon specifications
242 for _, option := range options {243 for _, option := range options {
243- iconName := getIconName(option, faviconCache)244+ iconName := getIconName(option, dataDir)
244 if iconName != "" {245 if iconName != "" {
245 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)246 fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName)
246 } else {247 } else {
@@ -344,19 +345,21 @@ func parseSelection(selection string) string {
344 }345 }
345 346
346 // getIconName determines the appropriate icon name for a dmenu option347 // getIconName determines the appropriate icon name for a dmenu option
347-func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string {348+func getIconName(option DmenuOption, dataDir string) string {
348 if option.Type != historyType {349 if option.Type != historyType {
349 return ""350 return ""
350 }351 }
351 352
352- // Only use cached favicons - no system theme fallbacks for consistent sizing353+ // Get exported favicon path from FaviconService
353- if option.FaviconURL != "" && faviconCache != nil {354+ // FaviconService exports all favicons as 32x32 PNG files for CLI access
354- cachedPath := faviconCache.GetCachedPath(option.FaviconURL)355+ if option.Value != "" {
355- if cachedPath != "" {356+ faviconPath := services.GetExportedFaviconPath(dataDir, option.Value)
356- return cachedPath357+ // Check if file exists and is recent (within 7 days)
358+ if info, err := os.Stat(faviconPath); err == nil {
359+ if time.Since(info.ModTime()) < 7*24*time.Hour {
360+ return faviconPath
361+ }
357 }362 }
358- // Start async download for next time
359- faviconCache.CacheAsync(option.FaviconURL)
360 }363 }
361 364
362 // No favicon available - return empty string to show no icon365 // No favicon available - return empty string to show no icon
M internal/cli/root.go +0 -8
@@ -8,7 +8,6 @@ import (
88 "os"
99 "path/filepath"
1010
11- "github.com/bnema/dumber/internal/cache"
1211 "github.com/bnema/dumber/internal/config"
1312 "github.com/bnema/dumber/internal/db"
1413 "github.com/bnema/dumber/internal/services"
@@ -77,13 +76,6 @@ func NewCLI() (*CLI, error) {
7776 queries := db.New(database)
7877 parserService := services.NewParserService(cfg, queries)
7978
80- // Clean old cached favicons on startup (async, non-blocking)
81- go func() {
82- if faviconCache, err := cache.NewFaviconCache(); err == nil {
83- faviconCache.CleanOld()
84- }
85- }()
86-
8779 return &CLI{
8880 DB: database,
8981 Queries: queries,
@@ -8,7 +8,6 @@ import (
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 10
11- "github.com/bnema/dumber/internal/cache"
12 "github.com/bnema/dumber/internal/config"11 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/db"12 "github.com/bnema/dumber/internal/db"
14 "github.com/bnema/dumber/internal/services"13 "github.com/bnema/dumber/internal/services"
@@ -77,13 +76,6 @@ func NewCLI() (*CLI, error) {
77 queries := db.New(database)76 queries := db.New(database)
78 parserService := services.NewParserService(cfg, queries)77 parserService := services.NewParserService(cfg, queries)
79 78
80- // Clean old cached favicons on startup (async, non-blocking)
81- go func() {
82- if faviconCache, err := cache.NewFaviconCache(); err == nil {
83- faviconCache.CleanOld()
84- }
85- }()
86-
87 return &CLI{79 return &CLI{
88 DB: database,80 DB: database,
89 Queries: queries,81 Queries: queries,
M internal/config/defaults.go +5 -5
@@ -32,11 +32,11 @@ const (
3232 defaultPopupPlacement = "right"
3333
3434 // Workspace styling defaults
35- defaultBorderWidth = 2
36- defaultBorderColor = "@theme_selected_bg_color"
37- defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator
38- defaultTransitionDuration = 120
39- defaultBorderRadius = 0
35+ defaultBorderWidth = 2
36+ defaultBorderColor = "@theme_selected_bg_color"
37+ defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator
38+ defaultTransitionDuration = 120
39+ defaultBorderRadius = 0
4040 )
4141
4242 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -32,11 +32,11 @@ const (
32 defaultPopupPlacement = "right"32 defaultPopupPlacement = "right"
33 33
34 // Workspace styling defaults34 // Workspace styling defaults
35- defaultBorderWidth = 235+ defaultBorderWidth = 2
36- defaultBorderColor = "@theme_selected_bg_color"36+ defaultBorderColor = "@theme_selected_bg_color"
37- defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator37+ defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator
38- defaultTransitionDuration = 12038+ defaultTransitionDuration = 120
39- defaultBorderRadius = 039+ defaultBorderRadius = 0
40 )40 )
41 41
42 // getDefaultLogDir returns the default log directory, falls back to empty string on error42 // getDefaultLogDir returns the default log directory, falls back to empty string on error
M internal/db/interfaces.go +1 -0
@@ -17,6 +17,7 @@ type ZoomQuerier interface {
1717 // HistoryQuerier defines the interface for history-related database operations
1818 type HistoryQuerier interface {
1919 GetHistory(ctx context.Context, limit int64) ([]History, error)
20+ GetHistoryEntry(ctx context.Context, url string) (History, error)
2021 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
2122 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
2223 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
@@ -17,6 +17,7 @@ type ZoomQuerier interface {
17 // HistoryQuerier defines the interface for history-related database operations17 // HistoryQuerier defines the interface for history-related database operations
18 type HistoryQuerier interface {18 type HistoryQuerier interface {
19 GetHistory(ctx context.Context, limit int64) ([]History, error)19 GetHistory(ctx context.Context, limit int64) ([]History, error)
20+ GetHistoryEntry(ctx context.Context, url string) (History, error)
20 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)21 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
21 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)22 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
22 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error23 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
M internal/migrations/migrations_test.go +4 -4
@@ -418,10 +418,10 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
418418
419419 // Expected tables based on current migrations
420420 expectedTables := []string{
421- "schema_migrations", // Migration tracking
422- "history", // 001_initial.sql
423- "shortcuts", // 001_initial.sql
424- "zoom_levels", // 003_add_zoom_levels.sql
421+ "schema_migrations", // Migration tracking
422+ "history", // 001_initial.sql
423+ "shortcuts", // 001_initial.sql
424+ "zoom_levels", // 003_add_zoom_levels.sql
425425 "certificate_validations", // 005_add_certificate_validations.sql
426426 }
427427
@@ -418,10 +418,10 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) {
418 418
419 // Expected tables based on current migrations419 // Expected tables based on current migrations
420 expectedTables := []string{420 expectedTables := []string{
421- "schema_migrations", // Migration tracking421+ "schema_migrations", // Migration tracking
422- "history", // 001_initial.sql422+ "history", // 001_initial.sql
423- "shortcuts", // 001_initial.sql423+ "shortcuts", // 001_initial.sql
424- "zoom_levels", // 003_add_zoom_levels.sql424+ "zoom_levels", // 003_add_zoom_levels.sql
425 "certificate_validations", // 005_add_certificate_validations.sql425 "certificate_validations", // 005_add_certificate_validations.sql
426 }426 }
427 427
M internal/services/browser.go +4 -106
@@ -3,7 +3,6 @@ package services
33
44 import (
55 "context"
6- "crypto/md5"
76 "database/sql"
87 "embed"
98 "errors"
@@ -13,7 +12,6 @@ import (
1312 neturl "net/url"
1413 "time"
1514
16- "github.com/bnema/dumber/internal/cache"
1715 "github.com/bnema/dumber/internal/config"
1816 "github.com/bnema/dumber/internal/db"
1917 "github.com/bnema/dumber/pkg/webkit"
@@ -91,20 +89,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
9189 // AttachWebView connects a native WebKit WebView to this service for integration.
9290 func (s *BrowserService) AttachWebView(view *webkit.WebView) {
9391 s.webView = view
94-
95- // Register legacy favicon change handler (binary data - fallback)
96- // This is kept for backward compatibility but is rarely used
97- // Modern favicon handling uses the URI-based handler registered in buildPane()
98- view.RegisterFaviconChangedHandler(func(data []byte) {
99- if currentURL := view.GetCurrentURL(); currentURL != "" {
100- log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))
101- s.handleFaviconChanged(currentURL, data)
102- }
103- })
104-
105- // Note: RegisterFaviconURIChangedHandler is now only called in buildPane()
106- // to avoid duplicate handler registration which causes multiple downloads
107- // of the same favicon and file handle race conditions
92+ // Favicon handling is now managed by FaviconService
10893 }
10994
11095 // LoadGUIBundle loads the unified GUI bundle from assets
@@ -302,11 +287,13 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
302287 result := make([]HistoryEntry, len(entries))
303288 for i, entry := range entries {
304289 vc := clampToInt32(entry.VisitCount.Int64)
290+ // For omnibox display, convert favicon URI to file path when available
291+ faviconURL := entry.FaviconUrl.String
305292 result[i] = HistoryEntry{
306293 ID: entry.ID,
307294 URL: entry.Url,
308295 Title: entry.Title.String,
309- FaviconURL: entry.FaviconUrl.String,
296+ FaviconURL: faviconURL,
310297 VisitCount: vc,
311298 LastVisited: entry.LastVisited.Time,
312299 CreatedAt: entry.CreatedAt.Time,
@@ -568,95 +555,6 @@ func zoomKeyFromURL(raw string) string {
568555 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
569556 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
570557
571-// handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
572-func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
573- log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))
574-
575- if len(pngData) == 0 {
576- log.Printf("[favicon] No favicon data for %s", pageURL)
577- return
578- }
579-
580- ctx := context.Background()
581-
582- // Parse URL for validation
583- parsedURL, err := neturl.Parse(pageURL)
584- if err != nil {
585- return
586- }
587-
588- // Skip favicon update for localhost, file://, or special schemes
589- if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
590- return
591- }
592- if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
593- return
594- }
595-
596- // Cache the PNG data directly
597- if faviconCache, err := cache.NewFaviconCache(); err == nil {
598- if err := faviconCache.CachePNGData(pageURL, pngData); err != nil {
599- // Log error but don't fail
600- log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err)
601- } else {
602- log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData))
603- }
604- } else {
605- log.Printf("[favicon] Failed to create favicon cache: %v", err)
606- }
607-
608- // Store a reference to the cached file in the database
609- // We use a consistent path format for the favicon URL
610- faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
611- faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
612- if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
613- // Silently fail - favicon is not critical
614- log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err)
615- }
616-}
617-
618-// ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview
619-func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) {
620- s.handleFaviconURIChanged(pageURL, faviconURI)
621-}
622-
623-// handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
624-func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
625- log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
626-
627- ctx := context.Background()
628-
629- // Parse URL for validation
630- parsedURL, err := neturl.Parse(pageURL)
631- if err != nil {
632- log.Printf("[favicon] Invalid page URL: %s", pageURL)
633- return
634- }
635-
636- // Skip favicon update for localhost, file://, or special schemes
637- if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
638- return
639- }
640- if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
641- return
642- }
643-
644- // Store the favicon URI directly in the database
645- // The frontend will handle SVG vs PNG/ICO detection
646- faviconNullString := sql.NullString{String: faviconURI, Valid: true}
647- if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
648- log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
649- }
650-
651- // Download and cache the favicon asynchronously (same approach as dmenu)
652- if faviconCache, err := cache.NewFaviconCache(); err == nil {
653- faviconCache.CacheAsync(faviconURI)
654- log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL)
655- } else {
656- log.Printf("[favicon] Failed to create favicon cache: %v", err)
657- }
658-}
659-
660558 // ColorPalettesResponse holds light and dark palettes for JSON marshaling
661559 type ColorPalettesResponse struct {
662560 Light config.ColorPalette `json:"light"`
@@ -3,7 +3,6 @@ package services
3 3
4 import (4 import (
5 "context"5 "context"
6- "crypto/md5"
7 "database/sql"6 "database/sql"
8 "embed"7 "embed"
9 "errors"8 "errors"
@@ -13,7 +12,6 @@ import (
13 neturl "net/url"12 neturl "net/url"
14 "time"13 "time"
15 14
16- "github.com/bnema/dumber/internal/cache"
17 "github.com/bnema/dumber/internal/config"15 "github.com/bnema/dumber/internal/config"
18 "github.com/bnema/dumber/internal/db"16 "github.com/bnema/dumber/internal/db"
19 "github.com/bnema/dumber/pkg/webkit"17 "github.com/bnema/dumber/pkg/webkit"
@@ -91,20 +89,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
91 // AttachWebView connects a native WebKit WebView to this service for integration.89 // AttachWebView connects a native WebKit WebView to this service for integration.
92 func (s *BrowserService) AttachWebView(view *webkit.WebView) {90 func (s *BrowserService) AttachWebView(view *webkit.WebView) {
93 s.webView = view91 s.webView = view
94-92+ // Favicon handling is now managed by FaviconService
95- // Register legacy favicon change handler (binary data - fallback)
96- // This is kept for backward compatibility but is rarely used
97- // Modern favicon handling uses the URI-based handler registered in buildPane()
98- view.RegisterFaviconChangedHandler(func(data []byte) {
99- if currentURL := view.GetCurrentURL(); currentURL != "" {
100- log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))
101- s.handleFaviconChanged(currentURL, data)
102- }
103- })
104-
105- // Note: RegisterFaviconURIChangedHandler is now only called in buildPane()
106- // to avoid duplicate handler registration which causes multiple downloads
107- // of the same favicon and file handle race conditions
108 }93 }
109 94
110 // LoadGUIBundle loads the unified GUI bundle from assets95 // LoadGUIBundle loads the unified GUI bundle from assets
@@ -302,11 +287,13 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
302 result := make([]HistoryEntry, len(entries))287 result := make([]HistoryEntry, len(entries))
303 for i, entry := range entries {288 for i, entry := range entries {
304 vc := clampToInt32(entry.VisitCount.Int64)289 vc := clampToInt32(entry.VisitCount.Int64)
290+ // For omnibox display, convert favicon URI to file path when available
291+ faviconURL := entry.FaviconUrl.String
305 result[i] = HistoryEntry{292 result[i] = HistoryEntry{
306 ID: entry.ID,293 ID: entry.ID,
307 URL: entry.Url,294 URL: entry.Url,
308 Title: entry.Title.String,295 Title: entry.Title.String,
309- FaviconURL: entry.FaviconUrl.String,296+ FaviconURL: faviconURL,
310 VisitCount: vc,297 VisitCount: vc,
311 LastVisited: entry.LastVisited.Time,298 LastVisited: entry.LastVisited.Time,
312 CreatedAt: entry.CreatedAt.Time,299 CreatedAt: entry.CreatedAt.Time,
@@ -568,95 +555,6 @@ func zoomKeyFromURL(raw string) string {
568 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.555 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
569 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }556 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
570 557
571-// handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
572-func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
573- log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))
574-
575- if len(pngData) == 0 {
576- log.Printf("[favicon] No favicon data for %s", pageURL)
577- return
578- }
579-
580- ctx := context.Background()
581-
582- // Parse URL for validation
583- parsedURL, err := neturl.Parse(pageURL)
584- if err != nil {
585- return
586- }
587-
588- // Skip favicon update for localhost, file://, or special schemes
589- if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
590- return
591- }
592- if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
593- return
594- }
595-
596- // Cache the PNG data directly
597- if faviconCache, err := cache.NewFaviconCache(); err == nil {
598- if err := faviconCache.CachePNGData(pageURL, pngData); err != nil {
599- // Log error but don't fail
600- log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err)
601- } else {
602- log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData))
603- }
604- } else {
605- log.Printf("[favicon] Failed to create favicon cache: %v", err)
606- }
607-
608- // Store a reference to the cached file in the database
609- // We use a consistent path format for the favicon URL
610- faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
611- faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
612- if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
613- // Silently fail - favicon is not critical
614- log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err)
615- }
616-}
617-
618-// ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview
619-func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) {
620- s.handleFaviconURIChanged(pageURL, faviconURI)
621-}
622-
623-// handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
624-func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
625- log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
626-
627- ctx := context.Background()
628-
629- // Parse URL for validation
630- parsedURL, err := neturl.Parse(pageURL)
631- if err != nil {
632- log.Printf("[favicon] Invalid page URL: %s", pageURL)
633- return
634- }
635-
636- // Skip favicon update for localhost, file://, or special schemes
637- if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
638- return
639- }
640- if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
641- return
642- }
643-
644- // Store the favicon URI directly in the database
645- // The frontend will handle SVG vs PNG/ICO detection
646- faviconNullString := sql.NullString{String: faviconURI, Valid: true}
647- if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
648- log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
649- }
650-
651- // Download and cache the favicon asynchronously (same approach as dmenu)
652- if faviconCache, err := cache.NewFaviconCache(); err == nil {
653- faviconCache.CacheAsync(faviconURI)
654- log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL)
655- } else {
656- log.Printf("[favicon] Failed to create favicon cache: %v", err)
657- }
658-}
659-
660 // ColorPalettesResponse holds light and dark palettes for JSON marshaling558 // ColorPalettesResponse holds light and dark palettes for JSON marshaling
661 type ColorPalettesResponse struct {559 type ColorPalettesResponse struct {
662 Light config.ColorPalette `json:"light"`560 Light config.ColorPalette `json:"light"`
A internal/services/favicon_service.go +454 -0
@@ -0,0 +1,454 @@
1+// Package services contains application services that orchestrate business logic.
2+package services
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "log"
9+ "os"
10+ "path/filepath"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/db"
14+ "github.com/bnema/dumber/pkg/webkit"
15+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
16+ "github.com/diamondburned/gotk4/pkg/gio/v2"
17+)
18+
19+// FaviconService provides unified favicon access using WebKitGTK's native database.
20+// The service keeps WebKit's FaviconDatabase as the source of truth and augments it
21+// with fallback rasterisation for SVG favicons so that callers always receive a
22+// 32x32 texture or an explicit error.
23+type FaviconService struct {
24+ faviconDB *webkit.FaviconDatabase
25+ dbQueries db.DatabaseQuerier
26+ targetSize int // Target size for exported favicons (32x32 for high quality)
27+ exportDir string // Directory for exporting favicons for CLI access
28+ dataDir string // WebKit data directory
29+ enableExport bool // Whether to export favicons for CLI tools
30+}
31+
32+const (
33+ // DefaultFaviconSize is the target size for exported favicons (32x32 for high quality)
34+ DefaultFaviconSize = 32
35+ // FaviconTimeout is the maximum time to wait for a favicon to be available
36+ FaviconTimeout = 5 * time.Second
37+)
38+
39+// ServiceName returns the service name for identification
40+func (fs *FaviconService) ServiceName() string {
41+ return "FaviconService"
42+}
43+
44+// NewFaviconService creates a new favicon service
45+func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQuerier, dataDir string) (*FaviconService, error) {
46+ if faviconDB == nil {
47+ return nil, fmt.Errorf("favicon database cannot be nil")
48+ }
49+ if queries == nil {
50+ return nil, fmt.Errorf("database queries cannot be nil")
51+ }
52+
53+ // Create export directory for CLI access
54+ exportDir := filepath.Join(dataDir, "favicons-export")
55+ if err := os.MkdirAll(exportDir, 0755); err != nil {
56+ return nil, fmt.Errorf("failed to create favicon export directory: %w", err)
57+ }
58+
59+ fs := &FaviconService{
60+ faviconDB: faviconDB,
61+ dbQueries: queries,
62+ targetSize: DefaultFaviconSize,
63+ exportDir: exportDir,
64+ dataDir: dataDir,
65+ enableExport: true,
66+ }
67+
68+ // Register the favicon changed handler ONCE at the service level
69+ // This is critical to avoid duplicate handlers when multiple WebViews exist
70+ // The FaviconDatabase is shared across all WebViews (at NetworkSession level)
71+ faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
72+ if err := fs.OnFaviconChanged(pageURI, faviconURI); err != nil {
73+ log.Printf("[favicon] Handler error for %s: %v", pageURI, err)
74+ }
75+ })
76+
77+ log.Printf("[favicon] Registered single shared favicon handler at FaviconService level")
78+
79+ return fs, nil
80+}
81+
82+// OnFaviconChanged handles favicon URI changes from WebKit's favicon database.
83+// We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture.
84+func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error {
85+ log.Printf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI)
86+
87+ ctx := context.Background()
88+
89+ if !fs.shouldProcessURL(pageURL) {
90+ return nil
91+ }
92+
93+ nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""}
94+ if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil {
95+ log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err)
96+ return fmt.Errorf("failed to update favicon in database: %w", err)
97+ }
98+
99+ // Also update all other URLs from the same domain to propagate favicon
100+ // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar)
101+ fs.propagateFaviconToDomain(pageURL, faviconURI)
102+
103+ // Proactively render and export favicon for omnibox/CLI use
104+ // This ensures favicons are available ASAP for suggestions
105+ if fs.enableExport {
106+ go fs.preloadFavicon(pageURL, faviconURI)
107+ }
108+
109+ return nil
110+}
111+
112+// propagateFaviconToDomain updates all history entries from the same domain
113+// with the favicon. This ensures multiple paths on the same domain share the favicon
114+// even if WebKit only signals it once per domain (KISS + DRY principle).
115+func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) {
116+ ctx := context.Background()
117+
118+ parsedURL, err := webkit.ParseURL(pageURL)
119+ if err != nil {
120+ return // Invalid URL, skip propagation
121+ }
122+
123+ faviconNull := sql.NullString{String: faviconURI, Valid: true}
124+
125+ // Search for exact domain match first
126+ domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true}
127+ entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000)
128+ if err != nil {
129+ log.Printf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err)
130+ return
131+ }
132+
133+ // Also search for www variant to handle www/non-www redirects
134+ // google.com should share favicon with www.google.com and vice versa
135+ var altPattern sql.NullString
136+ if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." {
137+ // Current is www.google.com, also search google.com
138+ hostWithoutWWW := parsedURL.Host[4:]
139+ altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true}
140+ } else {
141+ // Current is google.com, also search www.google.com
142+ altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true}
143+ }
144+
145+ altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000)
146+ entries = append(entries, altEntries...)
147+
148+ // Update all entries that don't already have a favicon
149+ for _, entry := range entries {
150+ if entry.FaviconUrl.String == "" {
151+ if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil {
152+ log.Printf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err)
153+ } else {
154+ log.Printf("[favicon] Propagated favicon to %s", entry.Url)
155+ }
156+ }
157+ }
158+}
159+
160+// preloadFavicon renders and exports a favicon in the background.
161+// This is called immediately when OnFaviconChanged fires to ensure
162+// favicons are ready for omnibox suggestions as soon as possible.
163+func (fs *FaviconService) preloadFavicon(pageURL, faviconURI string) {
164+ ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
165+ defer cancel()
166+
167+ texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
168+ if err != nil {
169+ log.Printf("[favicon] Preload failed for %s: %v", pageURL, err)
170+ return
171+ }
172+
173+ fs.maybeExportTexture(pageURL, texture)
174+ log.Printf("[favicon] Preloaded and exported favicon for %s", pageURL)
175+}
176+
177+// GetFaviconTexture loads a favicon texture asynchronously and calls the callback with the result.
178+// The callback receives a 32x32 GdkTexture suitable for display in GTK widgets, or an error.
179+func (fs *FaviconService) GetFaviconTexture(pageURL string, callback func(*gdk.Texture, error)) {
180+ if callback == nil {
181+ return
182+ }
183+ if pageURL == "" {
184+ fs.dispatchTexture(callback, nil, fmt.Errorf("page URL is empty"))
185+ return
186+ }
187+
188+ go func() {
189+ ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
190+ defer cancel()
191+
192+ faviconURI, err := fs.lookupFaviconURI(ctx, pageURL)
193+ if err != nil {
194+ fs.dispatchTexture(callback, nil, err)
195+ return
196+ }
197+
198+ texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
199+ if err != nil {
200+ fs.dispatchTexture(callback, nil, err)
201+ return
202+ }
203+
204+ fs.maybeExportTexture(pageURL, texture)
205+
206+ fs.dispatchTexture(callback, texture, nil)
207+ }()
208+}
209+
210+func (fs *FaviconService) renderFaviconTexture(ctx context.Context, pageURL, faviconURI string) (*gdk.Texture, error) {
211+ if faviconURI == "" {
212+ return nil, fmt.Errorf("no favicon URI available")
213+ }
214+
215+ if fs.isSVGFavicon(faviconURI) {
216+ return fs.loadSVGFaviconTexture(ctx, faviconURI)
217+ }
218+
219+ if fs.faviconDB == nil {
220+ return nil, fmt.Errorf("favicon database not initialized")
221+ }
222+
223+ type result struct {
224+ texture *gdk.Texture
225+ err error
226+ }
227+
228+ resultCh := make(chan result, 1)
229+
230+ fs.faviconDB.Favicon(ctx, pageURL, func(res gio.AsyncResulter) {
231+ if res == nil {
232+ select {
233+ case resultCh <- result{err: fmt.Errorf("favicon async result is nil")}:
234+ default:
235+ }
236+ return
237+ }
238+
239+ texture, err := webkit.FaviconFinishSafe(fs.faviconDB, res)
240+ if err == nil && texture == nil {
241+ err = fmt.Errorf("favicon unavailable")
242+ }
243+ if err == nil {
244+ texture = fs.ensureTextureSize(texture)
245+ if texture == nil {
246+ err = fmt.Errorf("failed to scale favicon texture")
247+ }
248+ }
249+
250+ select {
251+ case resultCh <- result{texture: texture, err: err}:
252+ default:
253+ }
254+ })
255+
256+ select {
257+ case <-ctx.Done():
258+ return nil, ctx.Err()
259+ case res := <-resultCh:
260+ return res.texture, res.err
261+ }
262+}
263+
264+func (fs *FaviconService) loadSVGFaviconTexture(ctx context.Context, faviconURI string) (*gdk.Texture, error) {
265+ file := gio.NewFileForURI(faviconURI)
266+ if file == nil {
267+ return nil, fmt.Errorf("invalid SVG favicon URI: %s", faviconURI)
268+ }
269+
270+ data, _, err := file.LoadContents(ctx)
271+ if err != nil {
272+ return nil, fmt.Errorf("load SVG favicon: %w", err)
273+ }
274+
275+ texture, err := webkit.TextureFromImageBytes(data)
276+ if err != nil {
277+ return nil, fmt.Errorf("decode SVG favicon: %w", err)
278+ }
279+
280+ texture = fs.ensureTextureSize(texture)
281+ if texture == nil {
282+ return nil, fmt.Errorf("failed to scale SVG favicon")
283+ }
284+
285+ return texture, nil
286+}
287+
288+func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string) (string, error) {
289+ entry, err := fs.dbQueries.GetHistoryEntry(ctx, pageURL)
290+ if err == nil {
291+ if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {
292+ return entry.FaviconUrl.String, nil
293+ }
294+ } else if err != sql.ErrNoRows {
295+ log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err)
296+ }
297+
298+ if fs.faviconDB != nil {
299+ if uri := fs.faviconDB.FaviconURI(pageURL); uri != "" {
300+ return uri, nil
301+ }
302+ }
303+
304+ return "", fmt.Errorf("no favicon URI available for %s", pageURL)
305+}
306+
307+func (fs *FaviconService) ensureTextureSize(texture *gdk.Texture) *gdk.Texture {
308+ if texture == nil {
309+ return nil
310+ }
311+
312+ if texture.Width() == fs.targetSize && texture.Height() == fs.targetSize {
313+ return texture
314+ }
315+
316+ return webkit.ScaleTexture(texture, fs.targetSize, fs.targetSize)
317+}
318+
319+func (fs *FaviconService) dispatchTexture(callback func(*gdk.Texture, error), texture *gdk.Texture, err error) {
320+ _ = webkit.IdleAdd(func() bool {
321+ callback(texture, err)
322+ return false
323+ })
324+}
325+
326+func (fs *FaviconService) maybeExportTexture(pageURL string, texture *gdk.Texture) {
327+ if !fs.enableExport || texture == nil {
328+ return
329+ }
330+
331+ exportPath := fs.getExportPath(pageURL)
332+
333+ _ = webkit.IdleAdd(func() bool {
334+ if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil {
335+ log.Printf("[favicon] Failed to export favicon PNG for %s: %v", pageURL, err)
336+ }
337+ return false
338+ })
339+}
340+
341+// GetFaviconPath returns the exported PNG path for CLI tools (dmenu, etc.)
342+// This allows CLI tools to display favicons without WebKitGTK API access.
343+func (fs *FaviconService) GetFaviconPath(pageURL string) (string, error) {
344+ exportPath := fs.getExportPath(pageURL)
345+ if info, err := os.Stat(exportPath); err == nil {
346+ if time.Since(info.ModTime()) < 7*24*time.Hour {
347+ return exportPath, nil
348+ }
349+ }
350+
351+ if !fs.enableExport {
352+ return "", fmt.Errorf("favicon export disabled")
353+ }
354+
355+ ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
356+ defer cancel()
357+
358+ faviconURI, err := fs.lookupFaviconURI(ctx, pageURL)
359+ if err != nil {
360+ return "", err
361+ }
362+
363+ texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
364+ if err != nil {
365+ return "", err
366+ }
367+
368+ if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil {
369+ return "", fmt.Errorf("failed to save favicon PNG: %w", err)
370+ }
371+
372+ return exportPath, nil
373+}
374+
375+// getExportPath generates the export path for a page URL
376+func (fs *FaviconService) getExportPath(pageURL string) string {
377+ // Use the same hashing scheme as the old cache for compatibility
378+ hash := fmt.Sprintf("%x", webkit.HashURL(pageURL))
379+ return filepath.Join(fs.exportDir, hash+".png")
380+}
381+
382+// shouldProcessURL checks if a URL should have its favicon processed
383+func (fs *FaviconService) shouldProcessURL(pageURL string) bool {
384+ parsedURL, err := webkit.ParseURL(pageURL)
385+ if err != nil {
386+ log.Printf("[favicon] Invalid page URL: %s", pageURL)
387+ return false
388+ }
389+
390+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
391+ return false
392+ }
393+ if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
394+ return false
395+ }
396+
397+ return true
398+}
399+
400+// isSVGFavicon checks if a favicon URI points to an SVG file.
401+func (fs *FaviconService) isSVGFavicon(faviconURI string) bool {
402+ if faviconURI == "" {
403+ return false
404+ }
405+
406+ parsedURL, err := webkit.ParseURL(faviconURI)
407+ if err != nil {
408+ return false
409+ }
410+
411+ path := parsedURL.Path
412+ if len(path) >= 4 {
413+ ext := path[len(path)-4:]
414+ if ext == ".svg" || ext == ".SVG" {
415+ return true
416+ }
417+ }
418+
419+ return false
420+}
421+
422+// CleanOldExports removes exported favicons older than 30 days
423+func (fs *FaviconService) CleanOldExports() error {
424+ entries, err := os.ReadDir(fs.exportDir)
425+ if err != nil {
426+ return err
427+ }
428+
429+ cutoff := time.Now().Add(-30 * 24 * time.Hour)
430+
431+ for _, entry := range entries {
432+ if entry.IsDir() {
433+ continue
434+ }
435+
436+ info, err := entry.Info()
437+ if err != nil {
438+ continue
439+ }
440+
441+ if info.ModTime().Before(cutoff) {
442+ os.Remove(filepath.Join(fs.exportDir, entry.Name()))
443+ }
444+ }
445+
446+ return nil
447+}
448+
449+// GetExportedFaviconPath returns the expected export path for a page URL.
450+func GetExportedFaviconPath(dataDir, pageURL string) string {
451+ exportDir := filepath.Join(dataDir, "webkit", "favicons-export")
452+ hash := fmt.Sprintf("%x", webkit.HashURL(pageURL))
453+ return filepath.Join(exportDir, hash+".png")
454+}
@@ -0,0 +1,454 @@
1+// Package services contains application services that orchestrate business logic.
2+package services
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "log"
9+ "os"
10+ "path/filepath"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/db"
14+ "github.com/bnema/dumber/pkg/webkit"
15+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
16+ "github.com/diamondburned/gotk4/pkg/gio/v2"
17+)
18+
19+// FaviconService provides unified favicon access using WebKitGTK's native database.
20+// The service keeps WebKit's FaviconDatabase as the source of truth and augments it
21+// with fallback rasterisation for SVG favicons so that callers always receive a
22+// 32x32 texture or an explicit error.
23+type FaviconService struct {
24+ faviconDB *webkit.FaviconDatabase
25+ dbQueries db.DatabaseQuerier
26+ targetSize int // Target size for exported favicons (32x32 for high quality)
27+ exportDir string // Directory for exporting favicons for CLI access
28+ dataDir string // WebKit data directory
29+ enableExport bool // Whether to export favicons for CLI tools
30+}
31+
32+const (
33+ // DefaultFaviconSize is the target size for exported favicons (32x32 for high quality)
34+ DefaultFaviconSize = 32
35+ // FaviconTimeout is the maximum time to wait for a favicon to be available
36+ FaviconTimeout = 5 * time.Second
37+)
38+
39+// ServiceName returns the service name for identification
40+func (fs *FaviconService) ServiceName() string {
41+ return "FaviconService"
42+}
43+
44+// NewFaviconService creates a new favicon service
45+func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQuerier, dataDir string) (*FaviconService, error) {
46+ if faviconDB == nil {
47+ return nil, fmt.Errorf("favicon database cannot be nil")
48+ }
49+ if queries == nil {
50+ return nil, fmt.Errorf("database queries cannot be nil")
51+ }
52+
53+ // Create export directory for CLI access
54+ exportDir := filepath.Join(dataDir, "favicons-export")
55+ if err := os.MkdirAll(exportDir, 0755); err != nil {
56+ return nil, fmt.Errorf("failed to create favicon export directory: %w", err)
57+ }
58+
59+ fs := &FaviconService{
60+ faviconDB: faviconDB,
61+ dbQueries: queries,
62+ targetSize: DefaultFaviconSize,
63+ exportDir: exportDir,
64+ dataDir: dataDir,
65+ enableExport: true,
66+ }
67+
68+ // Register the favicon changed handler ONCE at the service level
69+ // This is critical to avoid duplicate handlers when multiple WebViews exist
70+ // The FaviconDatabase is shared across all WebViews (at NetworkSession level)
71+ faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
72+ if err := fs.OnFaviconChanged(pageURI, faviconURI); err != nil {
73+ log.Printf("[favicon] Handler error for %s: %v", pageURI, err)
74+ }
75+ })
76+
77+ log.Printf("[favicon] Registered single shared favicon handler at FaviconService level")
78+
79+ return fs, nil
80+}
81+
82+// OnFaviconChanged handles favicon URI changes from WebKit's favicon database.
83+// We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture.
84+func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error {
85+ log.Printf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI)
86+
87+ ctx := context.Background()
88+
89+ if !fs.shouldProcessURL(pageURL) {
90+ return nil
91+ }
92+
93+ nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""}
94+ if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil {
95+ log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err)
96+ return fmt.Errorf("failed to update favicon in database: %w", err)
97+ }
98+
99+ // Also update all other URLs from the same domain to propagate favicon
100+ // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar)
101+ fs.propagateFaviconToDomain(pageURL, faviconURI)
102+
103+ // Proactively render and export favicon for omnibox/CLI use
104+ // This ensures favicons are available ASAP for suggestions
105+ if fs.enableExport {
106+ go fs.preloadFavicon(pageURL, faviconURI)
107+ }
108+
109+ return nil
110+}
111+
112+// propagateFaviconToDomain updates all history entries from the same domain
113+// with the favicon. This ensures multiple paths on the same domain share the favicon
114+// even if WebKit only signals it once per domain (KISS + DRY principle).
115+func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) {
116+ ctx := context.Background()
117+
118+ parsedURL, err := webkit.ParseURL(pageURL)
119+ if err != nil {
120+ return // Invalid URL, skip propagation
121+ }
122+
123+ faviconNull := sql.NullString{String: faviconURI, Valid: true}
124+
125+ // Search for exact domain match first
126+ domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true}
127+ entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000)
128+ if err != nil {
129+ log.Printf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err)
130+ return
131+ }
132+
133+ // Also search for www variant to handle www/non-www redirects
134+ // google.com should share favicon with www.google.com and vice versa
135+ var altPattern sql.NullString
136+ if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." {
137+ // Current is www.google.com, also search google.com
138+ hostWithoutWWW := parsedURL.Host[4:]
139+ altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true}
140+ } else {
141+ // Current is google.com, also search www.google.com
142+ altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true}
143+ }
144+
145+ altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000)
146+ entries = append(entries, altEntries...)
147+
148+ // Update all entries that don't already have a favicon
149+ for _, entry := range entries {
150+ if entry.FaviconUrl.String == "" {
151+ if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil {
152+ log.Printf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err)
153+ } else {
154+ log.Printf("[favicon] Propagated favicon to %s", entry.Url)
155+ }
156+ }
157+ }
158+}
159+
160+// preloadFavicon renders and exports a favicon in the background.
161+// This is called immediately when OnFaviconChanged fires to ensure
162+// favicons are ready for omnibox suggestions as soon as possible.
163+func (fs *FaviconService) preloadFavicon(pageURL, faviconURI string) {
164+ ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
165+ defer cancel()
166+
167+ texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
168+ if err != nil {
169+ log.Printf("[favicon] Preload failed for %s: %v", pageURL, err)
170+ return
171+ }
172+
173+ fs.maybeExportTexture(pageURL, texture)
174+ log.Printf("[favicon] Preloaded and exported favicon for %s", pageURL)
175+}
176+
177+// GetFaviconTexture loads a favicon texture asynchronously and calls the callback with the result.
178+// The callback receives a 32x32 GdkTexture suitable for display in GTK widgets, or an error.
179+func (fs *FaviconService) GetFaviconTexture(pageURL string, callback func(*gdk.Texture, error)) {
180+ if callback == nil {
181+ return
182+ }
183+ if pageURL == "" {
184+ fs.dispatchTexture(callback, nil, fmt.Errorf("page URL is empty"))
185+ return
186+ }
187+
188+ go func() {
189+ ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
190+ defer cancel()
191+
192+ faviconURI, err := fs.lookupFaviconURI(ctx, pageURL)
193+ if err != nil {
194+ fs.dispatchTexture(callback, nil, err)
195+ return
196+ }
197+
198+ texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
199+ if err != nil {
200+ fs.dispatchTexture(callback, nil, err)
201+ return
202+ }
203+
204+ fs.maybeExportTexture(pageURL, texture)
205+
206+ fs.dispatchTexture(callback, texture, nil)
207+ }()
208+}
209+
210+func (fs *FaviconService) renderFaviconTexture(ctx context.Context, pageURL, faviconURI string) (*gdk.Texture, error) {
211+ if faviconURI == "" {
212+ return nil, fmt.Errorf("no favicon URI available")
213+ }
214+
215+ if fs.isSVGFavicon(faviconURI) {
216+ return fs.loadSVGFaviconTexture(ctx, faviconURI)
217+ }
218+
219+ if fs.faviconDB == nil {
220+ return nil, fmt.Errorf("favicon database not initialized")
221+ }
222+
223+ type result struct {
224+ texture *gdk.Texture
225+ err error
226+ }
227+
228+ resultCh := make(chan result, 1)
229+
230+ fs.faviconDB.Favicon(ctx, pageURL, func(res gio.AsyncResulter) {
231+ if res == nil {
232+ select {
233+ case resultCh <- result{err: fmt.Errorf("favicon async result is nil")}:
234+ default:
235+ }
236+ return
237+ }
238+
239+ texture, err := webkit.FaviconFinishSafe(fs.faviconDB, res)
240+ if err == nil && texture == nil {
241+ err = fmt.Errorf("favicon unavailable")
242+ }
243+ if err == nil {
244+ texture = fs.ensureTextureSize(texture)
245+ if texture == nil {
246+ err = fmt.Errorf("failed to scale favicon texture")
247+ }
248+ }
249+
250+ select {
251+ case resultCh <- result{texture: texture, err: err}:
252+ default:
253+ }
254+ })
255+
256+ select {
257+ case <-ctx.Done():
258+ return nil, ctx.Err()
259+ case res := <-resultCh:
260+ return res.texture, res.err
261+ }
262+}
263+
264+func (fs *FaviconService) loadSVGFaviconTexture(ctx context.Context, faviconURI string) (*gdk.Texture, error) {
265+ file := gio.NewFileForURI(faviconURI)
266+ if file == nil {
267+ return nil, fmt.Errorf("invalid SVG favicon URI: %s", faviconURI)
268+ }
269+
270+ data, _, err := file.LoadContents(ctx)
271+ if err != nil {
272+ return nil, fmt.Errorf("load SVG favicon: %w", err)
273+ }
274+
275+ texture, err := webkit.TextureFromImageBytes(data)
276+ if err != nil {
277+ return nil, fmt.Errorf("decode SVG favicon: %w", err)
278+ }
279+
280+ texture = fs.ensureTextureSize(texture)
281+ if texture == nil {
282+ return nil, fmt.Errorf("failed to scale SVG favicon")
283+ }
284+
285+ return texture, nil
286+}
287+
288+func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string) (string, error) {
289+ entry, err := fs.dbQueries.GetHistoryEntry(ctx, pageURL)
290+ if err == nil {
291+ if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {
292+ return entry.FaviconUrl.String, nil
293+ }
294+ } else if err != sql.ErrNoRows {
295+ log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err)
296+ }
297+
298+ if fs.faviconDB != nil {
299+ if uri := fs.faviconDB.FaviconURI(pageURL); uri != "" {
300+ return uri, nil
301+ }
302+ }
303+
304+ return "", fmt.Errorf("no favicon URI available for %s", pageURL)
305+}
306+
307+func (fs *FaviconService) ensureTextureSize(texture *gdk.Texture) *gdk.Texture {
308+ if texture == nil {
309+ return nil
310+ }
311+
312+ if texture.Width() == fs.targetSize && texture.Height() == fs.targetSize {
313+ return texture
314+ }
315+
316+ return webkit.ScaleTexture(texture, fs.targetSize, fs.targetSize)
317+}
318+
319+func (fs *FaviconService) dispatchTexture(callback func(*gdk.Texture, error), texture *gdk.Texture, err error) {
320+ _ = webkit.IdleAdd(func() bool {
321+ callback(texture, err)
322+ return false
323+ })
324+}
325+
326+func (fs *FaviconService) maybeExportTexture(pageURL string, texture *gdk.Texture) {
327+ if !fs.enableExport || texture == nil {
328+ return
329+ }
330+
331+ exportPath := fs.getExportPath(pageURL)
332+
333+ _ = webkit.IdleAdd(func() bool {
334+ if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil {
335+ log.Printf("[favicon] Failed to export favicon PNG for %s: %v", pageURL, err)
336+ }
337+ return false
338+ })
339+}
340+
341+// GetFaviconPath returns the exported PNG path for CLI tools (dmenu, etc.)
342+// This allows CLI tools to display favicons without WebKitGTK API access.
343+func (fs *FaviconService) GetFaviconPath(pageURL string) (string, error) {
344+ exportPath := fs.getExportPath(pageURL)
345+ if info, err := os.Stat(exportPath); err == nil {
346+ if time.Since(info.ModTime()) < 7*24*time.Hour {
347+ return exportPath, nil
348+ }
349+ }
350+
351+ if !fs.enableExport {
352+ return "", fmt.Errorf("favicon export disabled")
353+ }
354+
355+ ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
356+ defer cancel()
357+
358+ faviconURI, err := fs.lookupFaviconURI(ctx, pageURL)
359+ if err != nil {
360+ return "", err
361+ }
362+
363+ texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
364+ if err != nil {
365+ return "", err
366+ }
367+
368+ if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil {
369+ return "", fmt.Errorf("failed to save favicon PNG: %w", err)
370+ }
371+
372+ return exportPath, nil
373+}
374+
375+// getExportPath generates the export path for a page URL
376+func (fs *FaviconService) getExportPath(pageURL string) string {
377+ // Use the same hashing scheme as the old cache for compatibility
378+ hash := fmt.Sprintf("%x", webkit.HashURL(pageURL))
379+ return filepath.Join(fs.exportDir, hash+".png")
380+}
381+
382+// shouldProcessURL checks if a URL should have its favicon processed
383+func (fs *FaviconService) shouldProcessURL(pageURL string) bool {
384+ parsedURL, err := webkit.ParseURL(pageURL)
385+ if err != nil {
386+ log.Printf("[favicon] Invalid page URL: %s", pageURL)
387+ return false
388+ }
389+
390+ if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
391+ return false
392+ }
393+ if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
394+ return false
395+ }
396+
397+ return true
398+}
399+
400+// isSVGFavicon checks if a favicon URI points to an SVG file.
401+func (fs *FaviconService) isSVGFavicon(faviconURI string) bool {
402+ if faviconURI == "" {
403+ return false
404+ }
405+
406+ parsedURL, err := webkit.ParseURL(faviconURI)
407+ if err != nil {
408+ return false
409+ }
410+
411+ path := parsedURL.Path
412+ if len(path) >= 4 {
413+ ext := path[len(path)-4:]
414+ if ext == ".svg" || ext == ".SVG" {
415+ return true
416+ }
417+ }
418+
419+ return false
420+}
421+
422+// CleanOldExports removes exported favicons older than 30 days
423+func (fs *FaviconService) CleanOldExports() error {
424+ entries, err := os.ReadDir(fs.exportDir)
425+ if err != nil {
426+ return err
427+ }
428+
429+ cutoff := time.Now().Add(-30 * 24 * time.Hour)
430+
431+ for _, entry := range entries {
432+ if entry.IsDir() {
433+ continue
434+ }
435+
436+ info, err := entry.Info()
437+ if err != nil {
438+ continue
439+ }
440+
441+ if info.ModTime().Before(cutoff) {
442+ os.Remove(filepath.Join(fs.exportDir, entry.Name()))
443+ }
444+ }
445+
446+ return nil
447+}
448+
449+// GetExportedFaviconPath returns the expected export path for a page URL.
450+func GetExportedFaviconPath(dataDir, pageURL string) string {
451+ exportDir := filepath.Join(dataDir, "webkit", "favicons-export")
452+ hash := fmt.Sprintf("%x", webkit.HashURL(pageURL))
453+ return filepath.Join(exportDir, hash+".png")
454+}
A internal/services/service.go +33 -0
@@ -0,0 +1,33 @@
1+// Package services defines the service interface and common service patterns
2+package services
3+
4+// Service is the base interface that all application services must implement
5+// This allows for consistent service management, lifecycle control, and dependency injection
6+type Service interface {
7+ // ServiceName returns the unique identifier for this service
8+ // Used for logging, debugging, and service registration
9+ ServiceName() string
10+}
11+
12+// Initializable services can be initialized after construction
13+type Initializable interface {
14+ Service
15+ // Initialize performs any setup required after construction
16+ // Returns an error if initialization fails
17+ Initialize() error
18+}
19+
20+// Cleanable services can cleanup resources on shutdown
21+type Cleanable interface {
22+ Service
23+ // Cleanup releases any resources held by the service
24+ // Should be idempotent and safe to call multiple times
25+ Cleanup() error
26+}
27+
28+// LifecycleService implements both Initializable and Cleanable
29+// Use this for services that need both initialization and cleanup
30+type LifecycleService interface {
31+ Initializable
32+ Cleanable
33+}
@@ -0,0 +1,33 @@
1+// Package services defines the service interface and common service patterns
2+package services
3+
4+// Service is the base interface that all application services must implement
5+// This allows for consistent service management, lifecycle control, and dependency injection
6+type Service interface {
7+ // ServiceName returns the unique identifier for this service
8+ // Used for logging, debugging, and service registration
9+ ServiceName() string
10+}
11+
12+// Initializable services can be initialized after construction
13+type Initializable interface {
14+ Service
15+ // Initialize performs any setup required after construction
16+ // Returns an error if initialization fails
17+ Initialize() error
18+}
19+
20+// Cleanable services can cleanup resources on shutdown
21+type Cleanable interface {
22+ Service
23+ // Cleanup releases any resources held by the service
24+ // Should be idempotent and safe to call multiple times
25+ Cleanup() error
26+}
27+
28+// LifecycleService implements both Initializable and Cleanable
29+// Use this for services that need both initialization and cleanup
30+type LifecycleService interface {
31+ Initializable
32+ Cleanable
33+}
A pkg/webkit/favicon_helpers.go +163 -0
@@ -0,0 +1,163 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: webkitgtk-6.0
5+#include <webkit/webkit.h>
6+*/
7+import "C"
8+
9+import (
10+ "context"
11+ "crypto/md5"
12+ "fmt"
13+ "net/url"
14+ "runtime"
15+ "unsafe"
16+
17+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
18+ "github.com/diamondburned/gotk4/pkg/core/gerror"
19+ coreglib "github.com/diamondburned/gotk4/pkg/core/glib"
20+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
21+ "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
22+ gio "github.com/diamondburned/gotk4/pkg/gio/v2"
23+ "github.com/diamondburned/gotk4/pkg/glib/v2"
24+)
25+
26+// GdkTexture is an alias for gotk4's Texture type
27+type GdkTexture = gdk.Texture
28+
29+// FaviconDatabase is an alias for WebKit's FaviconDatabase
30+type FaviconDatabase = webkit.FaviconDatabase
31+
32+// SaveTextureAsPNG saves a GdkTexture as a PNG file at target size
33+// WebKit provides GdkTexture which can be any format (ICO, PNG, JPEG, SVG)
34+// This function normalizes everything to PNG at the target size (32x32)
35+func SaveTextureAsPNG(texture *gdk.Texture, path string, targetSize int) error {
36+ if texture == nil {
37+ return fmt.Errorf("texture cannot be nil")
38+ }
39+
40+ width := texture.Width()
41+ height := texture.Height()
42+
43+ // If texture needs scaling, create scaled version
44+ if width != targetSize || height != targetSize {
45+ texture = ScaleTexture(texture, targetSize, targetSize)
46+ if texture == nil {
47+ return fmt.Errorf("failed to scale texture")
48+ }
49+ }
50+
51+ // Save texture as PNG using built-in method
52+ if !texture.SaveToPNG(path) {
53+ return fmt.Errorf("failed to save PNG to %s", path)
54+ }
55+
56+ return nil
57+}
58+
59+// ScaleTexture scales a texture to the target size using GdkPixbuf
60+// Uses high-quality bilinear interpolation
61+func ScaleTexture(texture *gdk.Texture, targetWidth, targetHeight int) *gdk.Texture {
62+ if texture == nil {
63+ return nil
64+ }
65+
66+ // Get PNG bytes from texture
67+ pngBytes := texture.SaveToPNGBytes()
68+ if pngBytes == nil {
69+ return nil
70+ }
71+
72+ // Load as pixbuf using memory input stream
73+ ctx := context.Background()
74+ stream := gio.NewMemoryInputStreamFromBytes(pngBytes)
75+ pixbuf, err := gdkpixbuf.NewPixbufFromStream(ctx, stream)
76+ if err != nil || pixbuf == nil {
77+ return nil
78+ }
79+
80+ // Scale pixbuf
81+ scaled := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.InterpBilinear)
82+ if scaled == nil {
83+ return nil
84+ }
85+
86+ // Convert back to texture
87+ return gdk.NewTextureForPixbuf(scaled)
88+}
89+
90+// HashURL generates an MD5 hash of a URL for filename generation
91+func HashURL(pageURL string) []byte {
92+ hash := md5.Sum([]byte(pageURL))
93+ return hash[:]
94+}
95+
96+// ParseURL is a convenience wrapper around url.Parse
97+func ParseURL(rawURL string) (*url.URL, error) {
98+ return url.Parse(rawURL)
99+}
100+
101+// FaviconFinishSafe mirrors gotk4's FaviconFinish but guards against NULL returns.
102+func FaviconFinishSafe(database *webkit.FaviconDatabase, result gio.AsyncResulter) (*gdk.Texture, error) {
103+ if database == nil || result == nil {
104+ return nil, fmt.Errorf("favicon finish: invalid arguments")
105+ }
106+
107+ cDatabase := (*C.WebKitFaviconDatabase)(unsafe.Pointer(glib.BaseObject(database).Native()))
108+ cResult := (*C.GAsyncResult)(unsafe.Pointer(glib.BaseObject(result).Native()))
109+
110+ var cErr *C.GError
111+ cTexture := C.webkit_favicon_database_get_favicon_finish(cDatabase, cResult, &cErr)
112+ runtime.KeepAlive(database)
113+ runtime.KeepAlive(result)
114+
115+ if cErr != nil {
116+ err := gerror.Take(unsafe.Pointer(cErr))
117+ return nil, err
118+ }
119+ if cTexture == nil {
120+ return nil, nil
121+ }
122+
123+ object := coreglib.AssumeOwnership(unsafe.Pointer(cTexture))
124+ casted := object.WalkCast(func(obj coreglib.Objector) bool {
125+ _, ok := obj.(gdk.Texturer)
126+ return ok
127+ })
128+
129+ texturer, ok := casted.(gdk.Texturer)
130+ if !ok {
131+ return nil, fmt.Errorf("favicon finish: unexpected type %T", casted)
132+ }
133+
134+ texture := gdk.BaseTexture(texturer)
135+ if texture == nil {
136+ return nil, fmt.Errorf("favicon finish: texture unavailable")
137+ }
138+
139+ return texture, nil
140+}
141+
142+// TextureFromImageBytes creates a texture from raw image bytes.
143+// Supported formats depend on the available GdkPixbuf loaders (PNG, SVG, etc.).
144+func TextureFromImageBytes(data []byte) (*gdk.Texture, error) {
145+ if len(data) == 0 {
146+ return nil, fmt.Errorf("image data is empty")
147+ }
148+
149+ // Convert []byte to glib.Bytes for gio API
150+ glibBytes := glib.NewBytes(data)
151+ stream := gio.NewMemoryInputStreamFromBytes(glibBytes)
152+ pixbuf, err := gdkpixbuf.NewPixbufFromStream(context.Background(), stream)
153+ if err != nil {
154+ return nil, fmt.Errorf("load pixbuf: %w", err)
155+ }
156+
157+ texture := gdk.NewTextureForPixbuf(pixbuf)
158+ if texture == nil {
159+ return nil, fmt.Errorf("create texture from pixbuf failed")
160+ }
161+
162+ return texture, nil
163+}
@@ -0,0 +1,163 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: webkitgtk-6.0
5+#include <webkit/webkit.h>
6+*/
7+import "C"
8+
9+import (
10+ "context"
11+ "crypto/md5"
12+ "fmt"
13+ "net/url"
14+ "runtime"
15+ "unsafe"
16+
17+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
18+ "github.com/diamondburned/gotk4/pkg/core/gerror"
19+ coreglib "github.com/diamondburned/gotk4/pkg/core/glib"
20+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
21+ "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
22+ gio "github.com/diamondburned/gotk4/pkg/gio/v2"
23+ "github.com/diamondburned/gotk4/pkg/glib/v2"
24+)
25+
26+// GdkTexture is an alias for gotk4's Texture type
27+type GdkTexture = gdk.Texture
28+
29+// FaviconDatabase is an alias for WebKit's FaviconDatabase
30+type FaviconDatabase = webkit.FaviconDatabase
31+
32+// SaveTextureAsPNG saves a GdkTexture as a PNG file at target size
33+// WebKit provides GdkTexture which can be any format (ICO, PNG, JPEG, SVG)
34+// This function normalizes everything to PNG at the target size (32x32)
35+func SaveTextureAsPNG(texture *gdk.Texture, path string, targetSize int) error {
36+ if texture == nil {
37+ return fmt.Errorf("texture cannot be nil")
38+ }
39+
40+ width := texture.Width()
41+ height := texture.Height()
42+
43+ // If texture needs scaling, create scaled version
44+ if width != targetSize || height != targetSize {
45+ texture = ScaleTexture(texture, targetSize, targetSize)
46+ if texture == nil {
47+ return fmt.Errorf("failed to scale texture")
48+ }
49+ }
50+
51+ // Save texture as PNG using built-in method
52+ if !texture.SaveToPNG(path) {
53+ return fmt.Errorf("failed to save PNG to %s", path)
54+ }
55+
56+ return nil
57+}
58+
59+// ScaleTexture scales a texture to the target size using GdkPixbuf
60+// Uses high-quality bilinear interpolation
61+func ScaleTexture(texture *gdk.Texture, targetWidth, targetHeight int) *gdk.Texture {
62+ if texture == nil {
63+ return nil
64+ }
65+
66+ // Get PNG bytes from texture
67+ pngBytes := texture.SaveToPNGBytes()
68+ if pngBytes == nil {
69+ return nil
70+ }
71+
72+ // Load as pixbuf using memory input stream
73+ ctx := context.Background()
74+ stream := gio.NewMemoryInputStreamFromBytes(pngBytes)
75+ pixbuf, err := gdkpixbuf.NewPixbufFromStream(ctx, stream)
76+ if err != nil || pixbuf == nil {
77+ return nil
78+ }
79+
80+ // Scale pixbuf
81+ scaled := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.InterpBilinear)
82+ if scaled == nil {
83+ return nil
84+ }
85+
86+ // Convert back to texture
87+ return gdk.NewTextureForPixbuf(scaled)
88+}
89+
90+// HashURL generates an MD5 hash of a URL for filename generation
91+func HashURL(pageURL string) []byte {
92+ hash := md5.Sum([]byte(pageURL))
93+ return hash[:]
94+}
95+
96+// ParseURL is a convenience wrapper around url.Parse
97+func ParseURL(rawURL string) (*url.URL, error) {
98+ return url.Parse(rawURL)
99+}
100+
101+// FaviconFinishSafe mirrors gotk4's FaviconFinish but guards against NULL returns.
102+func FaviconFinishSafe(database *webkit.FaviconDatabase, result gio.AsyncResulter) (*gdk.Texture, error) {
103+ if database == nil || result == nil {
104+ return nil, fmt.Errorf("favicon finish: invalid arguments")
105+ }
106+
107+ cDatabase := (*C.WebKitFaviconDatabase)(unsafe.Pointer(glib.BaseObject(database).Native()))
108+ cResult := (*C.GAsyncResult)(unsafe.Pointer(glib.BaseObject(result).Native()))
109+
110+ var cErr *C.GError
111+ cTexture := C.webkit_favicon_database_get_favicon_finish(cDatabase, cResult, &cErr)
112+ runtime.KeepAlive(database)
113+ runtime.KeepAlive(result)
114+
115+ if cErr != nil {
116+ err := gerror.Take(unsafe.Pointer(cErr))
117+ return nil, err
118+ }
119+ if cTexture == nil {
120+ return nil, nil
121+ }
122+
123+ object := coreglib.AssumeOwnership(unsafe.Pointer(cTexture))
124+ casted := object.WalkCast(func(obj coreglib.Objector) bool {
125+ _, ok := obj.(gdk.Texturer)
126+ return ok
127+ })
128+
129+ texturer, ok := casted.(gdk.Texturer)
130+ if !ok {
131+ return nil, fmt.Errorf("favicon finish: unexpected type %T", casted)
132+ }
133+
134+ texture := gdk.BaseTexture(texturer)
135+ if texture == nil {
136+ return nil, fmt.Errorf("favicon finish: texture unavailable")
137+ }
138+
139+ return texture, nil
140+}
141+
142+// TextureFromImageBytes creates a texture from raw image bytes.
143+// Supported formats depend on the available GdkPixbuf loaders (PNG, SVG, etc.).
144+func TextureFromImageBytes(data []byte) (*gdk.Texture, error) {
145+ if len(data) == 0 {
146+ return nil, fmt.Errorf("image data is empty")
147+ }
148+
149+ // Convert []byte to glib.Bytes for gio API
150+ glibBytes := glib.NewBytes(data)
151+ stream := gio.NewMemoryInputStreamFromBytes(glibBytes)
152+ pixbuf, err := gdkpixbuf.NewPixbufFromStream(context.Background(), stream)
153+ if err != nil {
154+ return nil, fmt.Errorf("load pixbuf: %w", err)
155+ }
156+
157+ texture := gdk.NewTextureForPixbuf(pixbuf)
158+ if texture == nil {
159+ return nil, fmt.Errorf("create texture from pixbuf failed")
160+ }
161+
162+ return texture, nil
163+}
M pkg/webkit/tls_handler.go +2 -2
@@ -18,8 +18,8 @@ import (
1818
1919 // TLSCertificateInfo holds information about a TLS certificate for display
2020 type TLSCertificateInfo struct {
21- Subject string
22- Issuer string
21+ Subject string
22+ Issuer string
2323 NotBefore string
2424 NotAfter string
2525 IsExpired bool
@@ -18,8 +18,8 @@ import (
18 18
19 // TLSCertificateInfo holds information about a TLS certificate for display19 // TLSCertificateInfo holds information about a TLS certificate for display
20 type TLSCertificateInfo struct {20 type TLSCertificateInfo struct {
21- Subject string21+ Subject string
22- Issuer string22+ Issuer string
23 NotBefore string23 NotBefore string
24 NotAfter string24 NotAfter string
25 IsExpired bool25 IsExpired bool
M pkg/webkit/webview.go +37 -23
@@ -9,6 +9,7 @@ import (
99 "sync/atomic"
1010
1111 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
1213 "github.com/diamondburned/gotk4/pkg/gtk/v4"
1314 )
1415
@@ -76,6 +77,16 @@ func NewWebView(cfg *Config) (*WebView, error) {
7677 return nil, ErrWebViewNotInitialized
7778 }
7879
80+ // Set background color based on user's theme preference to prevent white/black flash
81+ // This must be done before any content loads
82+ var bg gdk.RGBA
83+ if PrefersDarkTheme() {
84+ bg = gdk.NewRGBA(0.11, 0.11, 0.11, 1.0) // #1c1c1c - dark background
85+ } else {
86+ bg = gdk.NewRGBA(0.96, 0.96, 0.96, 1.0) // #f5f5f5 - light background
87+ }
88+ wkView.SetBackgroundColor(&bg)
89+
7990 // Verify the WebView is using the persistent session
8091 viewSession := wkView.NetworkSession()
8192 if viewSession == nil {
@@ -715,6 +726,29 @@ func (w *WebView) Window() *Window {
715726 return w.window
716727 }
717728
729+// GetFaviconDatabase returns the WebKit FaviconDatabase for this WebView
730+// Returns nil if the favicon database is not available
731+func (w *WebView) GetFaviconDatabase() *webkit.FaviconDatabase {
732+ w.mu.RLock()
733+ defer w.mu.RUnlock()
734+
735+ if w.destroyed || w.view == nil {
736+ return nil
737+ }
738+
739+ session := w.view.NetworkSession()
740+ if session == nil {
741+ return nil
742+ }
743+
744+ dataManager := session.WebsiteDataManager()
745+ if dataManager == nil {
746+ return nil
747+ }
748+
749+ return dataManager.FaviconDatabase()
750+}
751+
718752 // UpdateContentFilters updates the content filtering rules
719753 func (w *WebView) UpdateContentFilters(rules string) error {
720754 w.mu.RLock()
@@ -773,7 +807,9 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc
773807 w.isPaneModeActive = isActiveChecker
774808 }
775809
776-// setupFaviconHandlers connects to the FaviconDatabase signals
810+// setupFaviconHandlers enables favicons for this WebView
811+// Note: The actual favicon-changed handler is registered ONCE at the FaviconService level
812+// to avoid duplicate handlers when multiple WebViews exist
777813 func (w *WebView) setupFaviconHandlers() {
778814 // Get the NetworkSession from the WebView
779815 session := w.view.NetworkSession()
@@ -795,28 +831,6 @@ func (w *WebView) setupFaviconHandlers() {
795831 log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)
796832 }
797833
798- // Get the FaviconDatabase
799- faviconDB := dataManager.FaviconDatabase()
800- if faviconDB == nil {
801- log.Printf("[webkit] Warning: No FaviconDatabase available")
802- return
803- }
804-
805- // Connect to the favicon-changed signal
806- faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
807- log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI)
808-
809- // Call the URI handler if registered
810- w.mu.RLock()
811- handler := w.onFaviconURIChanged
812- w.mu.RUnlock()
813-
814- if handler != nil {
815- handler(pageURI, faviconURI)
816- }
817- })
818-
819- log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id)
820834 }
821835
822836 // GtkWebView returns the underlying gotk4 WebView for advanced operations
@@ -9,6 +9,7 @@ import (
9 "sync/atomic"9 "sync/atomic"
10 10
11 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"11 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
12 "github.com/diamondburned/gotk4/pkg/gtk/v4"13 "github.com/diamondburned/gotk4/pkg/gtk/v4"
13 )14 )
14 15
@@ -76,6 +77,16 @@ func NewWebView(cfg *Config) (*WebView, error) {
76 return nil, ErrWebViewNotInitialized77 return nil, ErrWebViewNotInitialized
77 }78 }
78 79
80+ // Set background color based on user's theme preference to prevent white/black flash
81+ // This must be done before any content loads
82+ var bg gdk.RGBA
83+ if PrefersDarkTheme() {
84+ bg = gdk.NewRGBA(0.11, 0.11, 0.11, 1.0) // #1c1c1c - dark background
85+ } else {
86+ bg = gdk.NewRGBA(0.96, 0.96, 0.96, 1.0) // #f5f5f5 - light background
87+ }
88+ wkView.SetBackgroundColor(&bg)
89+
79 // Verify the WebView is using the persistent session90 // Verify the WebView is using the persistent session
80 viewSession := wkView.NetworkSession()91 viewSession := wkView.NetworkSession()
81 if viewSession == nil {92 if viewSession == nil {
@@ -715,6 +726,29 @@ func (w *WebView) Window() *Window {
715 return w.window726 return w.window
716 }727 }
717 728
729+// GetFaviconDatabase returns the WebKit FaviconDatabase for this WebView
730+// Returns nil if the favicon database is not available
731+func (w *WebView) GetFaviconDatabase() *webkit.FaviconDatabase {
732+ w.mu.RLock()
733+ defer w.mu.RUnlock()
734+
735+ if w.destroyed || w.view == nil {
736+ return nil
737+ }
738+
739+ session := w.view.NetworkSession()
740+ if session == nil {
741+ return nil
742+ }
743+
744+ dataManager := session.WebsiteDataManager()
745+ if dataManager == nil {
746+ return nil
747+ }
748+
749+ return dataManager.FaviconDatabase()
750+}
751+
718 // UpdateContentFilters updates the content filtering rules752 // UpdateContentFilters updates the content filtering rules
719 func (w *WebView) UpdateContentFilters(rules string) error {753 func (w *WebView) UpdateContentFilters(rules string) error {
720 w.mu.RLock()754 w.mu.RLock()
@@ -773,7 +807,9 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc
773 w.isPaneModeActive = isActiveChecker807 w.isPaneModeActive = isActiveChecker
774 }808 }
775 809
776-// setupFaviconHandlers connects to the FaviconDatabase signals810+// setupFaviconHandlers enables favicons for this WebView
811+// Note: The actual favicon-changed handler is registered ONCE at the FaviconService level
812+// to avoid duplicate handlers when multiple WebViews exist
777 func (w *WebView) setupFaviconHandlers() {813 func (w *WebView) setupFaviconHandlers() {
778 // Get the NetworkSession from the WebView814 // Get the NetworkSession from the WebView
779 session := w.view.NetworkSession()815 session := w.view.NetworkSession()
@@ -795,28 +831,6 @@ func (w *WebView) setupFaviconHandlers() {
795 log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)831 log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)
796 }832 }
797 833
798- // Get the FaviconDatabase
799- faviconDB := dataManager.FaviconDatabase()
800- if faviconDB == nil {
801- log.Printf("[webkit] Warning: No FaviconDatabase available")
802- return
803- }
804-
805- // Connect to the favicon-changed signal
806- faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
807- log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI)
808-
809- // Call the URI handler if registered
810- w.mu.RLock()
811- handler := w.onFaviconURIChanged
812- w.mu.RUnlock()
813-
814- if handler != nil {
815- handler(pageURI, faviconURI)
816- }
817- })
818-
819- log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id)
820 }834 }
821 835
822 // GtkWebView returns the underlying gotk4 WebView for advanced operations836 // GtkWebView returns the underlying gotk4 WebView for advanced operations
M pkg/webkit/webview_related.go +2 -1
@@ -25,6 +25,7 @@ import (
2525
2626 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
2727 coreglib "github.com/diamondburned/gotk4/pkg/core/glib"
28+ "github.com/diamondburned/gotk4/pkg/glib/v2"
2829 "github.com/diamondburned/gotk4/pkg/gtk/v4"
2930 )
3031
@@ -41,7 +42,7 @@ func NewBareRelatedWebView(parentView *webkit.WebView) *webkit.WebView {
4142 }
4243
4344 // Get the native C pointer from the gotk4 WebView
44- parentObj := coreglib.InternObject(parentView)
45+ parentObj := glib.BaseObject(parentView)
4546 if parentObj == nil {
4647 log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView")
4748 return webkit.NewWebView()
@@ -25,6 +25,7 @@ import (
25 25
26 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"26 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
27 coreglib "github.com/diamondburned/gotk4/pkg/core/glib"27 coreglib "github.com/diamondburned/gotk4/pkg/core/glib"
28+ "github.com/diamondburned/gotk4/pkg/glib/v2"
28 "github.com/diamondburned/gotk4/pkg/gtk/v4"29 "github.com/diamondburned/gotk4/pkg/gtk/v4"
29 )30 )
30 31
@@ -41,7 +42,7 @@ func NewBareRelatedWebView(parentView *webkit.WebView) *webkit.WebView {
41 }42 }
42 43
43 // Get the native C pointer from the gotk4 WebView44 // Get the native C pointer from the gotk4 WebView
44- parentObj := coreglib.InternObject(parentView)45+ parentObj := glib.BaseObject(parentView)
45 if parentObj == nil {46 if parentObj == nil {
46 log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView")47 log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView")
47 return webkit.NewWebView()48 return webkit.NewWebView()
M pkg/webkit/window.go +13 -0
@@ -1,6 +1,8 @@
11 package webkit
22
33 import (
4+ "log"
5+
46 "github.com/diamondburned/gotk4/pkg/gtk/v4"
57 )
68
@@ -21,6 +23,17 @@ func NewWindow(title string) (*Window, error) {
2123 win.SetTitle(title)
2224 win.SetDefaultSize(1024, 768)
2325
26+ // Connect close-request signal to handle Cmd+Q and window close button
27+ // Returning true prevents the default close behavior and allows graceful shutdown
28+ win.ConnectCloseRequest(func() bool {
29+ log.Printf("[window] Close request received (Cmd+Q or close button) - initiating graceful shutdown")
30+ // Quit the main loop gracefully, which will trigger cleanup
31+ QuitMainLoop()
32+ // Return true to prevent GTK from destroying the window immediately
33+ // The cleanup process will handle window destruction
34+ return true
35+ })
36+
2437 return &Window{win: win}, nil
2538 }
2639
@@ -1,6 +1,8 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "log"
5+
4 "github.com/diamondburned/gotk4/pkg/gtk/v4"6 "github.com/diamondburned/gotk4/pkg/gtk/v4"
5 )7 )
6 8
@@ -21,6 +23,17 @@ func NewWindow(title string) (*Window, error) {
21 win.SetTitle(title)23 win.SetTitle(title)
22 win.SetDefaultSize(1024, 768)24 win.SetDefaultSize(1024, 768)
23 25
26+ // Connect close-request signal to handle Cmd+Q and window close button
27+ // Returning true prevents the default close behavior and allows graceful shutdown
28+ win.ConnectCloseRequest(func() bool {
29+ log.Printf("[window] Close request received (Cmd+Q or close button) - initiating graceful shutdown")
30+ // Quit the main loop gracefully, which will trigger cleanup
31+ QuitMainLoop()
32+ // Return true to prevent GTK from destroying the window immediately
33+ // The cleanup process will handle window destruction
34+ return true
35+ })
36+
24 return &Window{win: win}, nil37 return &Window{win: win}, nil
25 }38 }
26 39

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