detached 3011e5d

refactor(favicon): move favicon handling to clean architecture

Move favicon service from UI layer to domain and infrastructure layers with proper separation of concerns. Creates domain service interface, infrastructure implementations (fetcher, cache, service), and UI adapter for consumers.
commit
3011e5d99ccee37812b9614b84da3049f8d78a7e
author
bnema · · 2025-12-18 21:24 UTC
parent
c188211
signature
unsigned
12 files · +712 -451
M cmd/dumber/main.go +7 -1
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/domain/build"
1414 "github.com/bnema/dumber/internal/infrastructure/clipboard"
1515 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1617 "github.com/bnema/dumber/internal/infrastructure/media"
1718 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1819 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -183,6 +184,10 @@ func runGUI() {
183184 clipboardAdapter := clipboard.New()
184185 copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter)
185186
187+ // Create favicon service
188+ faviconCacheDir, _ := config.GetFaviconCacheDir()
189+ faviconService := favicon.NewService(faviconCacheDir)
190+
186191 // Build dependencies
187192 deps := &ui.Dependencies{
188193 Ctx: ctx,
@@ -207,7 +212,8 @@ func runGUI() {
207212 NavigateUC: navigateUC,
208213 CopyURLUC: copyURLUC,
209214 // Infrastructure Adapters
210- Clipboard: clipboardAdapter,
215+ Clipboard: clipboardAdapter,
216+ FaviconService: faviconService,
211217 }
212218
213219 // Run the application
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/domain/build"13 "github.com/bnema/dumber/internal/domain/build"
14 "github.com/bnema/dumber/internal/infrastructure/clipboard"14 "github.com/bnema/dumber/internal/infrastructure/clipboard"
15 "github.com/bnema/dumber/internal/infrastructure/config"15 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/favicon"
16 "github.com/bnema/dumber/internal/infrastructure/media"17 "github.com/bnema/dumber/internal/infrastructure/media"
17 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"18 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18 "github.com/bnema/dumber/internal/infrastructure/webkit"19 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -183,6 +184,10 @@ func runGUI() {
183 clipboardAdapter := clipboard.New()184 clipboardAdapter := clipboard.New()
184 copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter)185 copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter)
185 186
187+ // Create favicon service
188+ faviconCacheDir, _ := config.GetFaviconCacheDir()
189+ faviconService := favicon.NewService(faviconCacheDir)
190+
186 // Build dependencies191 // Build dependencies
187 deps := &ui.Dependencies{192 deps := &ui.Dependencies{
188 Ctx: ctx,193 Ctx: ctx,
@@ -207,7 +212,8 @@ func runGUI() {
207 NavigateUC: navigateUC,212 NavigateUC: navigateUC,
208 CopyURLUC: copyURLUC,213 CopyURLUC: copyURLUC,
209 // Infrastructure Adapters214 // Infrastructure Adapters
210- Clipboard: clipboardAdapter,215+ Clipboard: clipboardAdapter,
216+ FaviconService: faviconService,
211 }217 }
212 218
213 // Run the application219 // Run the application
A internal/domain/service/favicon.go +26 -0
@@ -0,0 +1,26 @@
1+// Package service defines domain service interfaces.
2+package service
3+
4+import "context"
5+
6+// FaviconService provides favicon retrieval and caching operations.
7+// It works with raw bytes, leaving texture conversion to the UI layer.
8+type FaviconService interface {
9+ // Get returns favicon bytes for a domain.
10+ // Checks memory cache, then disk cache, then fetches from external API.
11+ Get(ctx context.Context, domain string) ([]byte, error)
12+
13+ // GetCached returns favicon bytes only if already cached (no external fetch).
14+ // Returns the bytes and true if found, nil and false otherwise.
15+ GetCached(domain string) ([]byte, bool)
16+
17+ // Store saves favicon bytes for a domain to cache.
18+ Store(domain string, data []byte) error
19+
20+ // DiskPath returns the filesystem path where a domain's favicon is cached.
21+ // Used by CLI tools (rofi/fuzzel) that need file paths for icon display.
22+ DiskPath(domain string) string
23+
24+ // Close shuts down background workers and releases resources.
25+ Close()
26+}
@@ -0,0 +1,26 @@
1+// Package service defines domain service interfaces.
2+package service
3+
4+import "context"
5+
6+// FaviconService provides favicon retrieval and caching operations.
7+// It works with raw bytes, leaving texture conversion to the UI layer.
8+type FaviconService interface {
9+ // Get returns favicon bytes for a domain.
10+ // Checks memory cache, then disk cache, then fetches from external API.
11+ Get(ctx context.Context, domain string) ([]byte, error)
12+
13+ // GetCached returns favicon bytes only if already cached (no external fetch).
14+ // Returns the bytes and true if found, nil and false otherwise.
15+ GetCached(domain string) ([]byte, bool)
16+
17+ // Store saves favicon bytes for a domain to cache.
18+ Store(domain string, data []byte) error
19+
20+ // DiskPath returns the filesystem path where a domain's favicon is cached.
21+ // Used by CLI tools (rofi/fuzzel) that need file paths for icon display.
22+ DiskPath(domain string) string
23+
24+ // Close shuts down background workers and releases resources.
25+ Close()
26+}
A internal/infrastructure/favicon/cache.go +204 -0
@@ -0,0 +1,204 @@
1+package favicon
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "sync"
7+
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+)
10+
11+const (
12+ // diskWriteBufferSize defines the capacity of the favicon write channel.
13+ diskWriteBufferSize = 100
14+ // File permissions for favicon cache.
15+ diskCacheDirPerm = 0750
16+ diskCacheFilePerm = 0600
17+)
18+
19+// diskWrite represents a favicon to be written to disk asynchronously.
20+type diskWrite struct {
21+ domain string
22+ data []byte
23+}
24+
25+// Cache provides multi-tier favicon caching (memory + disk).
26+type Cache struct {
27+ memCache map[string][]byte
28+ diskDir string
29+ writeChan chan diskWrite
30+ closeOnce sync.Once
31+ mu sync.RWMutex
32+}
33+
34+// NewCache creates a new favicon cache.
35+// If diskDir is empty, only in-memory caching is used.
36+func NewCache(diskDir string) *Cache {
37+ c := &Cache{
38+ memCache: make(map[string][]byte),
39+ diskDir: diskDir,
40+ writeChan: make(chan diskWrite, diskWriteBufferSize),
41+ }
42+
43+ // Start background writer goroutine if disk caching is enabled
44+ if diskDir != "" {
45+ go c.diskWriter()
46+ }
47+
48+ return c
49+}
50+
51+// Get retrieves favicon bytes for a domain.
52+// Checks memory cache first, then disk cache.
53+// Returns the bytes and true if found, nil and false otherwise.
54+func (c *Cache) Get(domain string) ([]byte, bool) {
55+ if domain == "" {
56+ return nil, false
57+ }
58+
59+ // Check memory cache first
60+ c.mu.RLock()
61+ data, ok := c.memCache[domain]
62+ c.mu.RUnlock()
63+ if ok {
64+ return data, true
65+ }
66+
67+ // Check disk cache
68+ data = c.loadFromDisk(domain)
69+ if data != nil {
70+ // Populate memory cache
71+ c.mu.Lock()
72+ c.memCache[domain] = data
73+ c.mu.Unlock()
74+ return data, true
75+ }
76+
77+ return nil, false
78+}
79+
80+// Set stores favicon bytes for a domain.
81+// Writes to memory cache immediately and queues async disk write.
82+func (c *Cache) Set(domain string, data []byte) {
83+ if domain == "" || len(data) == 0 {
84+ return
85+ }
86+
87+ // Write to memory cache
88+ c.mu.Lock()
89+ c.memCache[domain] = data
90+ c.mu.Unlock()
91+
92+ // Queue async disk write
93+ c.queueDiskWrite(domain, data)
94+}
95+
96+// DiskPath returns the filesystem path for a domain's cached favicon.
97+// Returns empty string if disk caching is disabled or domain is empty.
98+func (c *Cache) DiskPath(domain string) string {
99+ if c.diskDir == "" || domain == "" {
100+ return ""
101+ }
102+ filename := domainurl.SanitizeDomainForFilename(domain)
103+ return filepath.Join(c.diskDir, filename)
104+}
105+
106+// HasOnDisk checks if a favicon exists on disk for the given domain.
107+func (c *Cache) HasOnDisk(domain string) bool {
108+ path := c.DiskPath(domain)
109+ if path == "" {
110+ return false
111+ }
112+ _, err := os.Stat(path)
113+ return err == nil
114+}
115+
116+// Close shuts down the background writer goroutine.
117+func (c *Cache) Close() {
118+ c.closeOnce.Do(func() {
119+ if c.writeChan != nil {
120+ close(c.writeChan)
121+ }
122+ })
123+}
124+
125+// Clear removes all entries from the in-memory cache.
126+func (c *Cache) Clear() {
127+ c.mu.Lock()
128+ c.memCache = make(map[string][]byte)
129+ c.mu.Unlock()
130+}
131+
132+// Size returns the number of entries in the in-memory cache.
133+func (c *Cache) Size() int {
134+ c.mu.RLock()
135+ size := len(c.memCache)
136+ c.mu.RUnlock()
137+ return size
138+}
139+
140+// loadFromDisk attempts to load favicon bytes from disk cache.
141+func (c *Cache) loadFromDisk(domain string) []byte {
142+ path := c.DiskPath(domain)
143+ if path == "" {
144+ return nil
145+ }
146+
147+ //nolint:gosec // path is constructed from sanitized domain
148+ data, err := os.ReadFile(path)
149+ if err != nil {
150+ return nil
151+ }
152+
153+ if len(data) == 0 {
154+ return nil
155+ }
156+
157+ return data
158+}
159+
160+// writeToDisk atomically writes favicon data to disk.
161+func (c *Cache) writeToDisk(domain string, data []byte) {
162+ if c.diskDir == "" || len(data) == 0 {
163+ return
164+ }
165+
166+ // Ensure directory exists
167+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
168+ return
169+ }
170+
171+ filename := domainurl.SanitizeDomainForFilename(domain)
172+ finalPath := filepath.Join(c.diskDir, filename)
173+ tempPath := finalPath + ".tmp"
174+
175+ // Write to temp file
176+ if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
177+ return
178+ }
179+
180+ // Atomic rename
181+ if err := os.Rename(tempPath, finalPath); err != nil {
182+ _ = os.Remove(tempPath)
183+ }
184+}
185+
186+// queueDiskWrite sends favicon data to be written asynchronously.
187+func (c *Cache) queueDiskWrite(domain string, data []byte) {
188+ if c.diskDir == "" {
189+ return
190+ }
191+ select {
192+ case c.writeChan <- diskWrite{domain: domain, data: data}:
193+ // queued successfully
194+ default:
195+ // channel full, skip write
196+ }
197+}
198+
199+// diskWriter processes async write requests.
200+func (c *Cache) diskWriter() {
201+ for write := range c.writeChan {
202+ c.writeToDisk(write.domain, write.data)
203+ }
204+}
@@ -0,0 +1,204 @@
1+package favicon
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "sync"
7+
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+)
10+
11+const (
12+ // diskWriteBufferSize defines the capacity of the favicon write channel.
13+ diskWriteBufferSize = 100
14+ // File permissions for favicon cache.
15+ diskCacheDirPerm = 0750
16+ diskCacheFilePerm = 0600
17+)
18+
19+// diskWrite represents a favicon to be written to disk asynchronously.
20+type diskWrite struct {
21+ domain string
22+ data []byte
23+}
24+
25+// Cache provides multi-tier favicon caching (memory + disk).
26+type Cache struct {
27+ memCache map[string][]byte
28+ diskDir string
29+ writeChan chan diskWrite
30+ closeOnce sync.Once
31+ mu sync.RWMutex
32+}
33+
34+// NewCache creates a new favicon cache.
35+// If diskDir is empty, only in-memory caching is used.
36+func NewCache(diskDir string) *Cache {
37+ c := &Cache{
38+ memCache: make(map[string][]byte),
39+ diskDir: diskDir,
40+ writeChan: make(chan diskWrite, diskWriteBufferSize),
41+ }
42+
43+ // Start background writer goroutine if disk caching is enabled
44+ if diskDir != "" {
45+ go c.diskWriter()
46+ }
47+
48+ return c
49+}
50+
51+// Get retrieves favicon bytes for a domain.
52+// Checks memory cache first, then disk cache.
53+// Returns the bytes and true if found, nil and false otherwise.
54+func (c *Cache) Get(domain string) ([]byte, bool) {
55+ if domain == "" {
56+ return nil, false
57+ }
58+
59+ // Check memory cache first
60+ c.mu.RLock()
61+ data, ok := c.memCache[domain]
62+ c.mu.RUnlock()
63+ if ok {
64+ return data, true
65+ }
66+
67+ // Check disk cache
68+ data = c.loadFromDisk(domain)
69+ if data != nil {
70+ // Populate memory cache
71+ c.mu.Lock()
72+ c.memCache[domain] = data
73+ c.mu.Unlock()
74+ return data, true
75+ }
76+
77+ return nil, false
78+}
79+
80+// Set stores favicon bytes for a domain.
81+// Writes to memory cache immediately and queues async disk write.
82+func (c *Cache) Set(domain string, data []byte) {
83+ if domain == "" || len(data) == 0 {
84+ return
85+ }
86+
87+ // Write to memory cache
88+ c.mu.Lock()
89+ c.memCache[domain] = data
90+ c.mu.Unlock()
91+
92+ // Queue async disk write
93+ c.queueDiskWrite(domain, data)
94+}
95+
96+// DiskPath returns the filesystem path for a domain's cached favicon.
97+// Returns empty string if disk caching is disabled or domain is empty.
98+func (c *Cache) DiskPath(domain string) string {
99+ if c.diskDir == "" || domain == "" {
100+ return ""
101+ }
102+ filename := domainurl.SanitizeDomainForFilename(domain)
103+ return filepath.Join(c.diskDir, filename)
104+}
105+
106+// HasOnDisk checks if a favicon exists on disk for the given domain.
107+func (c *Cache) HasOnDisk(domain string) bool {
108+ path := c.DiskPath(domain)
109+ if path == "" {
110+ return false
111+ }
112+ _, err := os.Stat(path)
113+ return err == nil
114+}
115+
116+// Close shuts down the background writer goroutine.
117+func (c *Cache) Close() {
118+ c.closeOnce.Do(func() {
119+ if c.writeChan != nil {
120+ close(c.writeChan)
121+ }
122+ })
123+}
124+
125+// Clear removes all entries from the in-memory cache.
126+func (c *Cache) Clear() {
127+ c.mu.Lock()
128+ c.memCache = make(map[string][]byte)
129+ c.mu.Unlock()
130+}
131+
132+// Size returns the number of entries in the in-memory cache.
133+func (c *Cache) Size() int {
134+ c.mu.RLock()
135+ size := len(c.memCache)
136+ c.mu.RUnlock()
137+ return size
138+}
139+
140+// loadFromDisk attempts to load favicon bytes from disk cache.
141+func (c *Cache) loadFromDisk(domain string) []byte {
142+ path := c.DiskPath(domain)
143+ if path == "" {
144+ return nil
145+ }
146+
147+ //nolint:gosec // path is constructed from sanitized domain
148+ data, err := os.ReadFile(path)
149+ if err != nil {
150+ return nil
151+ }
152+
153+ if len(data) == 0 {
154+ return nil
155+ }
156+
157+ return data
158+}
159+
160+// writeToDisk atomically writes favicon data to disk.
161+func (c *Cache) writeToDisk(domain string, data []byte) {
162+ if c.diskDir == "" || len(data) == 0 {
163+ return
164+ }
165+
166+ // Ensure directory exists
167+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
168+ return
169+ }
170+
171+ filename := domainurl.SanitizeDomainForFilename(domain)
172+ finalPath := filepath.Join(c.diskDir, filename)
173+ tempPath := finalPath + ".tmp"
174+
175+ // Write to temp file
176+ if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
177+ return
178+ }
179+
180+ // Atomic rename
181+ if err := os.Rename(tempPath, finalPath); err != nil {
182+ _ = os.Remove(tempPath)
183+ }
184+}
185+
186+// queueDiskWrite sends favicon data to be written asynchronously.
187+func (c *Cache) queueDiskWrite(domain string, data []byte) {
188+ if c.diskDir == "" {
189+ return
190+ }
191+ select {
192+ case c.writeChan <- diskWrite{domain: domain, data: data}:
193+ // queued successfully
194+ default:
195+ // channel full, skip write
196+ }
197+}
198+
199+// diskWriter processes async write requests.
200+func (c *Cache) diskWriter() {
201+ for write := range c.writeChan {
202+ c.writeToDisk(write.domain, write.data)
203+ }
204+}
A internal/infrastructure/favicon/fetcher.go +79 -0
@@ -0,0 +1,79 @@
1+// Package favicon provides favicon fetching and caching infrastructure.
2+package favicon
3+
4+import (
5+ "context"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ // DuckDuckGo favicon API URL template.
17+ duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
18+ // HTTP client timeout for favicon fetch.
19+ fetchTimeout = 5 * time.Second
20+)
21+
22+// Fetcher retrieves favicons from external APIs.
23+type Fetcher struct {
24+ client *http.Client
25+}
26+
27+// NewFetcher creates a new Fetcher with default HTTP client settings.
28+func NewFetcher() *Fetcher {
29+ return &Fetcher{
30+ client: &http.Client{
31+ Timeout: fetchTimeout,
32+ },
33+ }
34+}
35+
36+// Fetch retrieves favicon bytes for a domain from DuckDuckGo's favicon API.
37+// Returns nil bytes if the favicon cannot be fetched.
38+func (f *Fetcher) Fetch(ctx context.Context, domain string) ([]byte, error) {
39+ if domain == "" {
40+ return nil, nil
41+ }
42+
43+ log := logging.FromContext(ctx)
44+ faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
45+
46+ log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
47+
48+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
49+ if err != nil {
50+ log.Debug().Err(err).Msg("failed to create favicon request")
51+ return nil, err
52+ }
53+
54+ resp, err := f.client.Do(req)
55+ if err != nil {
56+ log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
57+ return nil, err
58+ }
59+ defer resp.Body.Close()
60+
61+ if resp.StatusCode != http.StatusOK {
62+ log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
63+ return nil, nil
64+ }
65+
66+ data, err := io.ReadAll(resp.Body)
67+ if err != nil {
68+ log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
69+ return nil, err
70+ }
71+
72+ if len(data) == 0 {
73+ log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
74+ return nil, nil
75+ }
76+
77+ log.Debug().Str("domain", domain).Int("bytes", len(data)).Msg("favicon fetched from DuckDuckGo")
78+ return data, nil
79+}
@@ -0,0 +1,79 @@
1+// Package favicon provides favicon fetching and caching infrastructure.
2+package favicon
3+
4+import (
5+ "context"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ // DuckDuckGo favicon API URL template.
17+ duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
18+ // HTTP client timeout for favicon fetch.
19+ fetchTimeout = 5 * time.Second
20+)
21+
22+// Fetcher retrieves favicons from external APIs.
23+type Fetcher struct {
24+ client *http.Client
25+}
26+
27+// NewFetcher creates a new Fetcher with default HTTP client settings.
28+func NewFetcher() *Fetcher {
29+ return &Fetcher{
30+ client: &http.Client{
31+ Timeout: fetchTimeout,
32+ },
33+ }
34+}
35+
36+// Fetch retrieves favicon bytes for a domain from DuckDuckGo's favicon API.
37+// Returns nil bytes if the favicon cannot be fetched.
38+func (f *Fetcher) Fetch(ctx context.Context, domain string) ([]byte, error) {
39+ if domain == "" {
40+ return nil, nil
41+ }
42+
43+ log := logging.FromContext(ctx)
44+ faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
45+
46+ log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
47+
48+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
49+ if err != nil {
50+ log.Debug().Err(err).Msg("failed to create favicon request")
51+ return nil, err
52+ }
53+
54+ resp, err := f.client.Do(req)
55+ if err != nil {
56+ log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
57+ return nil, err
58+ }
59+ defer resp.Body.Close()
60+
61+ if resp.StatusCode != http.StatusOK {
62+ log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
63+ return nil, nil
64+ }
65+
66+ data, err := io.ReadAll(resp.Body)
67+ if err != nil {
68+ log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
69+ return nil, err
70+ }
71+
72+ if len(data) == 0 {
73+ log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
74+ return nil, nil
75+ }
76+
77+ log.Debug().Str("domain", domain).Int("bytes", len(data)).Msg("favicon fetched from DuckDuckGo")
78+ return data, nil
79+}
A internal/infrastructure/favicon/service.go +96 -0
@@ -0,0 +1,96 @@
1+package favicon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+)
8+
9+// Service implements the domain FaviconService interface.
10+// It coordinates between the cache and fetcher components.
11+type Service struct {
12+ cache *Cache
13+ fetcher *Fetcher
14+}
15+
16+// NewService creates a new favicon service.
17+// cacheDir is the directory for disk caching; empty string disables disk caching.
18+func NewService(cacheDir string) *Service {
19+ return &Service{
20+ cache: NewCache(cacheDir),
21+ fetcher: NewFetcher(),
22+ }
23+}
24+
25+// Get returns favicon bytes for a domain.
26+// Checks memory cache, then disk cache, then fetches from external API.
27+func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
28+ if domain == "" {
29+ return nil, nil
30+ }
31+
32+ // Check cache first (memory + disk)
33+ if data, ok := s.cache.Get(domain); ok {
34+ return data, nil
35+ }
36+
37+ // Fetch from external API
38+ data, err := s.fetcher.Fetch(ctx, domain)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ // Store in cache if we got data
44+ if len(data) > 0 {
45+ s.cache.Set(domain, data)
46+ }
47+
48+ return data, nil
49+}
50+
51+// GetCached returns favicon bytes only if already cached (no external fetch).
52+func (s *Service) GetCached(domain string) ([]byte, bool) {
53+ return s.cache.Get(domain)
54+}
55+
56+// Store saves favicon bytes for a domain to cache.
57+func (s *Service) Store(domain string, data []byte) error {
58+ s.cache.Set(domain, data)
59+ return nil
60+}
61+
62+// DiskPath returns the filesystem path where a domain's favicon is cached.
63+func (s *Service) DiskPath(domain string) string {
64+ return s.cache.DiskPath(domain)
65+}
66+
67+// Close shuts down background workers and releases resources.
68+func (s *Service) Close() {
69+ s.cache.Close()
70+}
71+
72+// EnsureDiskCache fetches favicon from external API and saves to disk if not already cached.
73+// This is used when we receive a texture from WebKit but want to ensure disk persistence.
74+func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
75+ if domain == "" {
76+ return
77+ }
78+
79+ // Already on disk?
80+ if s.cache.HasOnDisk(domain) {
81+ return
82+ }
83+
84+ log := logging.FromContext(ctx)
85+
86+ // Fetch and store
87+ data, err := s.fetcher.Fetch(ctx, domain)
88+ if err != nil {
89+ log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon for disk cache")
90+ return
91+ }
92+
93+ if len(data) > 0 {
94+ s.cache.Set(domain, data)
95+ }
96+}
@@ -0,0 +1,96 @@
1+package favicon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/logging"
7+)
8+
9+// Service implements the domain FaviconService interface.
10+// It coordinates between the cache and fetcher components.
11+type Service struct {
12+ cache *Cache
13+ fetcher *Fetcher
14+}
15+
16+// NewService creates a new favicon service.
17+// cacheDir is the directory for disk caching; empty string disables disk caching.
18+func NewService(cacheDir string) *Service {
19+ return &Service{
20+ cache: NewCache(cacheDir),
21+ fetcher: NewFetcher(),
22+ }
23+}
24+
25+// Get returns favicon bytes for a domain.
26+// Checks memory cache, then disk cache, then fetches from external API.
27+func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
28+ if domain == "" {
29+ return nil, nil
30+ }
31+
32+ // Check cache first (memory + disk)
33+ if data, ok := s.cache.Get(domain); ok {
34+ return data, nil
35+ }
36+
37+ // Fetch from external API
38+ data, err := s.fetcher.Fetch(ctx, domain)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ // Store in cache if we got data
44+ if len(data) > 0 {
45+ s.cache.Set(domain, data)
46+ }
47+
48+ return data, nil
49+}
50+
51+// GetCached returns favicon bytes only if already cached (no external fetch).
52+func (s *Service) GetCached(domain string) ([]byte, bool) {
53+ return s.cache.Get(domain)
54+}
55+
56+// Store saves favicon bytes for a domain to cache.
57+func (s *Service) Store(domain string, data []byte) error {
58+ s.cache.Set(domain, data)
59+ return nil
60+}
61+
62+// DiskPath returns the filesystem path where a domain's favicon is cached.
63+func (s *Service) DiskPath(domain string) string {
64+ return s.cache.DiskPath(domain)
65+}
66+
67+// Close shuts down background workers and releases resources.
68+func (s *Service) Close() {
69+ s.cache.Close()
70+}
71+
72+// EnsureDiskCache fetches favicon from external API and saves to disk if not already cached.
73+// This is used when we receive a texture from WebKit but want to ensure disk persistence.
74+func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
75+ if domain == "" {
76+ return
77+ }
78+
79+ // Already on disk?
80+ if s.cache.HasOnDisk(domain) {
81+ return
82+ }
83+
84+ log := logging.FromContext(ctx)
85+
86+ // Fetch and store
87+ data, err := s.fetcher.Fetch(ctx, domain)
88+ if err != nil {
89+ log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon for disk cache")
90+ return
91+ }
92+
93+ if len(data) > 0 {
94+ s.cache.Set(domain, data)
95+ }
96+}
M internal/infrastructure/webkit/context.go +4 -0
@@ -11,6 +11,10 @@ import (
1111 "github.com/rs/zerolog"
1212 )
1313
14+// FaviconDatabase is a type alias for webkit.FaviconDatabase.
15+// Re-exported for use by UI layer without direct puregotk-webkit import.
16+type FaviconDatabase = webkit.FaviconDatabase
17+
1418 // WebKitContext manages the shared WebContext and persistent NetworkSession.
1519 // IMPORTANT: This MUST be initialized before creating any WebViews.
1620 type WebKitContext struct {
@@ -11,6 +11,10 @@ import (
11 "github.com/rs/zerolog"11 "github.com/rs/zerolog"
12 )12 )
13 13
14+// FaviconDatabase is a type alias for webkit.FaviconDatabase.
15+// Re-exported for use by UI layer without direct puregotk-webkit import.
16+type FaviconDatabase = webkit.FaviconDatabase
17+
14 // WebKitContext manages the shared WebContext and persistent NetworkSession.18 // WebKitContext manages the shared WebContext and persistent NetworkSession.
15 // IMPORTANT: This MUST be initialized before creating any WebViews.19 // IMPORTANT: This MUST be initialized before creating any WebViews.
16 type WebKitContext struct {20 type WebKitContext struct {
A internal/ui/adapter/favicon.go +254 -0
@@ -0,0 +1,254 @@
1+// Package adapter provides UI-layer adapters that bridge domain services to GTK.
2+package adapter
3+
4+import (
5+ "context"
6+ "sync"
7+
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+ "github.com/bnema/dumber/internal/infrastructure/favicon"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/gio"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
15+)
16+
17+// FaviconAdapter bridges the domain FaviconService to GTK by providing
18+// gdk.Texture conversion and WebKit FaviconDatabase integration.
19+type FaviconAdapter struct {
20+ service *favicon.Service
21+ faviconDB *webkit.FaviconDatabase
22+ textureCache map[string]*gdk.Texture
23+ mu sync.RWMutex
24+}
25+
26+// NewFaviconAdapter creates a new FaviconAdapter.
27+// The faviconDB can be nil if WebKit favicon database is not available.
28+func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
29+ return &FaviconAdapter{
30+ service: service,
31+ faviconDB: faviconDB,
32+ textureCache: make(map[string]*gdk.Texture),
33+ }
34+}
35+
36+// GetTexture returns a cached texture for the domain, or nil if not cached.
37+func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
38+ if domain == "" {
39+ return nil
40+ }
41+
42+ a.mu.RLock()
43+ texture := a.textureCache[domain]
44+ a.mu.RUnlock()
45+ return texture
46+}
47+
48+// GetTextureByURL returns a cached texture by extracting domain from URL.
49+func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
50+ domain := domainurl.ExtractDomain(pageURL)
51+ return a.GetTexture(domain)
52+}
53+
54+// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
55+// The callback is invoked on the GTK main thread with the texture (or nil).
56+func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
57+ if callback == nil {
58+ return
59+ }
60+
61+ log := logging.FromContext(ctx)
62+ domain := domainurl.ExtractDomain(pageURL)
63+
64+ // Check texture cache first
65+ if texture := a.GetTexture(domain); texture != nil {
66+ callback(texture)
67+ return
68+ }
69+
70+ // Check service cache (memory + disk) and convert to texture
71+ if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
72+ texture := bytesToTexture(data)
73+ if texture != nil {
74+ a.setTexture(domain, texture)
75+ callback(texture)
76+ return
77+ }
78+ }
79+
80+ // Helper to invoke callback on main thread
81+ invokeCallback := func(texture *gdk.Texture) {
82+ cb := glib.SourceFunc(func(uintptr) bool {
83+ callback(texture)
84+ return false
85+ })
86+ glib.IdleAdd(&cb, 0)
87+ }
88+
89+ // Helper to fetch via service and invoke callback
90+ fetchViaService := func() {
91+ go func() {
92+ data, err := a.service.Get(ctx, domain)
93+ if err != nil || len(data) == 0 {
94+ invokeCallback(nil)
95+ return
96+ }
97+
98+ texture := bytesToTexture(data)
99+ if texture != nil {
100+ a.setTexture(domain, texture)
101+ }
102+ invokeCallback(texture)
103+ }()
104+ }
105+
106+ // Try WebKit FaviconDatabase if available
107+ if a.faviconDB != nil {
108+ asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
109+ if resultPtr == 0 {
110+ log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
111+ fetchViaService()
112+ return
113+ }
114+
115+ result := &gio.AsyncResultBase{Ptr: resultPtr}
116+ texture, err := a.faviconDB.GetFaviconFinish(result)
117+ if err != nil || texture == nil {
118+ log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
119+ fetchViaService()
120+ return
121+ }
122+
123+ // Found in WebKit database
124+ a.setTexture(domain, texture)
125+ invokeCallback(texture)
126+
127+ // Ensure disk cache is populated (async)
128+ go a.service.EnsureDiskCache(ctx, domain)
129+ })
130+
131+ a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
132+ return
133+ }
134+
135+ // No WebKit DB, fetch via service directly
136+ fetchViaService()
137+}
138+
139+// StoreFromWebKit stores a favicon texture received from WebKit signals.
140+// Also ensures the favicon is persisted to disk cache via the service.
141+func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
142+ if texture == nil {
143+ return
144+ }
145+
146+ domain := domainurl.ExtractDomain(pageURL)
147+ if domain == "" {
148+ return
149+ }
150+
151+ // Store in texture cache
152+ a.setTexture(domain, texture)
153+
154+ // Ensure disk cache is populated (async, in background)
155+ go a.service.EnsureDiskCache(ctx, domain)
156+}
157+
158+// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
159+// Used when redirects occur (e.g., example.com → www.example.com).
160+func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
161+ ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
162+) {
163+ a.StoreFromWebKit(ctx, currentURL, texture)
164+
165+ // Also store under original URL domain if different
166+ if originURL != "" && originURL != currentURL {
167+ originDomain := domainurl.ExtractDomain(originURL)
168+ currentDomain := domainurl.ExtractDomain(currentURL)
169+ if originDomain != "" && originDomain != currentDomain {
170+ a.setTexture(originDomain, texture)
171+ go a.service.EnsureDiskCache(ctx, originDomain)
172+ }
173+ }
174+}
175+
176+// PreloadFromCache attempts to load a favicon from cache without fetching.
177+// Returns the texture if found in memory or disk cache, nil otherwise.
178+func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
179+ domain := domainurl.ExtractDomain(pageURL)
180+ if domain == "" {
181+ return nil
182+ }
183+
184+ // Check texture cache
185+ if texture := a.GetTexture(domain); texture != nil {
186+ return texture
187+ }
188+
189+ // Check service cache (memory + disk)
190+ if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
191+ texture := bytesToTexture(data)
192+ if texture != nil {
193+ a.setTexture(domain, texture)
194+ return texture
195+ }
196+ }
197+
198+ return nil
199+}
200+
201+// Service returns the underlying favicon service.
202+// Used by CLI components that need disk paths.
203+func (a *FaviconAdapter) Service() *favicon.Service {
204+ return a.service
205+}
206+
207+// Close shuts down the adapter and underlying service.
208+func (a *FaviconAdapter) Close() {
209+ a.service.Close()
210+}
211+
212+// Clear removes all entries from the texture cache.
213+func (a *FaviconAdapter) Clear() {
214+ a.mu.Lock()
215+ a.textureCache = make(map[string]*gdk.Texture)
216+ a.mu.Unlock()
217+}
218+
219+// Size returns the number of entries in the texture cache.
220+func (a *FaviconAdapter) Size() int {
221+ a.mu.RLock()
222+ size := len(a.textureCache)
223+ a.mu.RUnlock()
224+ return size
225+}
226+
227+// setTexture stores a texture in the cache.
228+func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
229+ if domain == "" || texture == nil {
230+ return
231+ }
232+ a.mu.Lock()
233+ a.textureCache[domain] = texture
234+ a.mu.Unlock()
235+}
236+
237+// bytesToTexture converts raw favicon bytes to a GDK texture.
238+func bytesToTexture(data []byte) *gdk.Texture {
239+ if len(data) == 0 {
240+ return nil
241+ }
242+
243+ bytes := glib.NewBytes(data, uint(len(data)))
244+ if bytes == nil {
245+ return nil
246+ }
247+
248+ texture, err := gdk.NewTextureFromBytes(bytes)
249+ if err != nil {
250+ return nil
251+ }
252+
253+ return texture
254+}
@@ -0,0 +1,254 @@
1+// Package adapter provides UI-layer adapters that bridge domain services to GTK.
2+package adapter
3+
4+import (
5+ "context"
6+ "sync"
7+
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+ "github.com/bnema/dumber/internal/infrastructure/favicon"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/gio"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
15+)
16+
17+// FaviconAdapter bridges the domain FaviconService to GTK by providing
18+// gdk.Texture conversion and WebKit FaviconDatabase integration.
19+type FaviconAdapter struct {
20+ service *favicon.Service
21+ faviconDB *webkit.FaviconDatabase
22+ textureCache map[string]*gdk.Texture
23+ mu sync.RWMutex
24+}
25+
26+// NewFaviconAdapter creates a new FaviconAdapter.
27+// The faviconDB can be nil if WebKit favicon database is not available.
28+func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
29+ return &FaviconAdapter{
30+ service: service,
31+ faviconDB: faviconDB,
32+ textureCache: make(map[string]*gdk.Texture),
33+ }
34+}
35+
36+// GetTexture returns a cached texture for the domain, or nil if not cached.
37+func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
38+ if domain == "" {
39+ return nil
40+ }
41+
42+ a.mu.RLock()
43+ texture := a.textureCache[domain]
44+ a.mu.RUnlock()
45+ return texture
46+}
47+
48+// GetTextureByURL returns a cached texture by extracting domain from URL.
49+func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
50+ domain := domainurl.ExtractDomain(pageURL)
51+ return a.GetTexture(domain)
52+}
53+
54+// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
55+// The callback is invoked on the GTK main thread with the texture (or nil).
56+func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
57+ if callback == nil {
58+ return
59+ }
60+
61+ log := logging.FromContext(ctx)
62+ domain := domainurl.ExtractDomain(pageURL)
63+
64+ // Check texture cache first
65+ if texture := a.GetTexture(domain); texture != nil {
66+ callback(texture)
67+ return
68+ }
69+
70+ // Check service cache (memory + disk) and convert to texture
71+ if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
72+ texture := bytesToTexture(data)
73+ if texture != nil {
74+ a.setTexture(domain, texture)
75+ callback(texture)
76+ return
77+ }
78+ }
79+
80+ // Helper to invoke callback on main thread
81+ invokeCallback := func(texture *gdk.Texture) {
82+ cb := glib.SourceFunc(func(uintptr) bool {
83+ callback(texture)
84+ return false
85+ })
86+ glib.IdleAdd(&cb, 0)
87+ }
88+
89+ // Helper to fetch via service and invoke callback
90+ fetchViaService := func() {
91+ go func() {
92+ data, err := a.service.Get(ctx, domain)
93+ if err != nil || len(data) == 0 {
94+ invokeCallback(nil)
95+ return
96+ }
97+
98+ texture := bytesToTexture(data)
99+ if texture != nil {
100+ a.setTexture(domain, texture)
101+ }
102+ invokeCallback(texture)
103+ }()
104+ }
105+
106+ // Try WebKit FaviconDatabase if available
107+ if a.faviconDB != nil {
108+ asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
109+ if resultPtr == 0 {
110+ log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
111+ fetchViaService()
112+ return
113+ }
114+
115+ result := &gio.AsyncResultBase{Ptr: resultPtr}
116+ texture, err := a.faviconDB.GetFaviconFinish(result)
117+ if err != nil || texture == nil {
118+ log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
119+ fetchViaService()
120+ return
121+ }
122+
123+ // Found in WebKit database
124+ a.setTexture(domain, texture)
125+ invokeCallback(texture)
126+
127+ // Ensure disk cache is populated (async)
128+ go a.service.EnsureDiskCache(ctx, domain)
129+ })
130+
131+ a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
132+ return
133+ }
134+
135+ // No WebKit DB, fetch via service directly
136+ fetchViaService()
137+}
138+
139+// StoreFromWebKit stores a favicon texture received from WebKit signals.
140+// Also ensures the favicon is persisted to disk cache via the service.
141+func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
142+ if texture == nil {
143+ return
144+ }
145+
146+ domain := domainurl.ExtractDomain(pageURL)
147+ if domain == "" {
148+ return
149+ }
150+
151+ // Store in texture cache
152+ a.setTexture(domain, texture)
153+
154+ // Ensure disk cache is populated (async, in background)
155+ go a.service.EnsureDiskCache(ctx, domain)
156+}
157+
158+// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
159+// Used when redirects occur (e.g., example.com → www.example.com).
160+func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
161+ ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
162+) {
163+ a.StoreFromWebKit(ctx, currentURL, texture)
164+
165+ // Also store under original URL domain if different
166+ if originURL != "" && originURL != currentURL {
167+ originDomain := domainurl.ExtractDomain(originURL)
168+ currentDomain := domainurl.ExtractDomain(currentURL)
169+ if originDomain != "" && originDomain != currentDomain {
170+ a.setTexture(originDomain, texture)
171+ go a.service.EnsureDiskCache(ctx, originDomain)
172+ }
173+ }
174+}
175+
176+// PreloadFromCache attempts to load a favicon from cache without fetching.
177+// Returns the texture if found in memory or disk cache, nil otherwise.
178+func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
179+ domain := domainurl.ExtractDomain(pageURL)
180+ if domain == "" {
181+ return nil
182+ }
183+
184+ // Check texture cache
185+ if texture := a.GetTexture(domain); texture != nil {
186+ return texture
187+ }
188+
189+ // Check service cache (memory + disk)
190+ if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
191+ texture := bytesToTexture(data)
192+ if texture != nil {
193+ a.setTexture(domain, texture)
194+ return texture
195+ }
196+ }
197+
198+ return nil
199+}
200+
201+// Service returns the underlying favicon service.
202+// Used by CLI components that need disk paths.
203+func (a *FaviconAdapter) Service() *favicon.Service {
204+ return a.service
205+}
206+
207+// Close shuts down the adapter and underlying service.
208+func (a *FaviconAdapter) Close() {
209+ a.service.Close()
210+}
211+
212+// Clear removes all entries from the texture cache.
213+func (a *FaviconAdapter) Clear() {
214+ a.mu.Lock()
215+ a.textureCache = make(map[string]*gdk.Texture)
216+ a.mu.Unlock()
217+}
218+
219+// Size returns the number of entries in the texture cache.
220+func (a *FaviconAdapter) Size() int {
221+ a.mu.RLock()
222+ size := len(a.textureCache)
223+ a.mu.RUnlock()
224+ return size
225+}
226+
227+// setTexture stores a texture in the cache.
228+func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
229+ if domain == "" || texture == nil {
230+ return
231+ }
232+ a.mu.Lock()
233+ a.textureCache[domain] = texture
234+ a.mu.Unlock()
235+}
236+
237+// bytesToTexture converts raw favicon bytes to a GDK texture.
238+func bytesToTexture(data []byte) *gdk.Texture {
239+ if len(data) == 0 {
240+ return nil
241+ }
242+
243+ bytes := glib.NewBytes(data, uint(len(data)))
244+ if bytes == nil {
245+ return nil
246+ }
247+
248+ texture, err := gdk.NewTextureFromBytes(bytes)
249+ if err != nil {
250+ return nil
251+ }
252+
253+ return texture
254+}
M internal/ui/app.go +10 -10
@@ -15,7 +15,7 @@ import (
1515 "github.com/bnema/dumber/internal/infrastructure/webkit"
1616 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
1717 "github.com/bnema/dumber/internal/logging"
18- "github.com/bnema/dumber/internal/ui/cache"
18+ "github.com/bnema/dumber/internal/ui/adapter"
1919 "github.com/bnema/dumber/internal/ui/component"
2020 "github.com/bnema/dumber/internal/ui/coordinator"
2121 "github.com/bnema/dumber/internal/ui/dispatcher"
@@ -69,7 +69,7 @@ type App struct {
6969 injector *webkit.ContentInjector
7070 router *webkit.MessageRouter
7171 settings *webkit.SettingsManager
72- faviconCache *cache.FaviconCache
72+ faviconAdapter *adapter.FaviconAdapter
7373
7474 // ID generator for tabs/panes
7575 idCounter uint64
@@ -214,7 +214,7 @@ func (a *App) onActivate(ctx context.Context) {
214214 a.omniboxCfg = component.OmniboxConfig{
215215 HistoryUC: a.deps.HistoryUC,
216216 FavoritesUC: a.deps.FavoritesUC,
217- FaviconCache: a.faviconCache,
217+ FaviconAdapter: a.faviconAdapter,
218218 CopyURLUC: a.deps.CopyURLUC,
219219 Shortcuts: a.deps.Config.SearchShortcuts,
220220 DefaultSearch: a.deps.Config.DefaultSearchEngine,
@@ -251,8 +251,8 @@ func (a *App) onShutdown(ctx context.Context) {
251251 a.cancel(errors.New("application shutdown"))
252252
253253 // Cleanup resources
254- if a.faviconCache != nil {
255- a.faviconCache.Close()
254+ if a.faviconAdapter != nil {
255+ a.faviconAdapter.Close()
256256 }
257257 if a.deps.Pool != nil {
258258 a.deps.Pool.Close(ctx)
@@ -271,19 +271,19 @@ func (a *App) initCoordinators(ctx context.Context) {
271271 return a.activeWorkspace(), a.activeWorkspaceView()
272272 }
273273
274- // Create FaviconCache with FaviconDatabase from WebKitContext
274+ // Create FaviconAdapter with service and WebKit FaviconDatabase
275+ var faviconDB *webkit.FaviconDatabase
275276 if a.deps.WebContext != nil {
276- a.faviconCache = cache.NewFaviconCache(a.deps.WebContext.FaviconDatabase())
277- } else {
278- a.faviconCache = cache.NewFaviconCache(nil)
277+ faviconDB = a.deps.WebContext.FaviconDatabase()
279278 }
279+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
280280
281281 // 1. Content Coordinator (no dependencies on other coordinators)
282282 a.contentCoord = coordinator.NewContentCoordinator(
283283 ctx,
284284 a.pool,
285285 a.widgetFactory,
286- a.faviconCache,
286+ a.faviconAdapter,
287287 getActiveWS,
288288 a.deps.ZoomUC,
289289 )
@@ -15,7 +15,7 @@ import (
15 "github.com/bnema/dumber/internal/infrastructure/webkit"15 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"16 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
17 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
18- "github.com/bnema/dumber/internal/ui/cache"18+ "github.com/bnema/dumber/internal/ui/adapter"
19 "github.com/bnema/dumber/internal/ui/component"19 "github.com/bnema/dumber/internal/ui/component"
20 "github.com/bnema/dumber/internal/ui/coordinator"20 "github.com/bnema/dumber/internal/ui/coordinator"
21 "github.com/bnema/dumber/internal/ui/dispatcher"21 "github.com/bnema/dumber/internal/ui/dispatcher"
@@ -69,7 +69,7 @@ type App struct {
69 injector *webkit.ContentInjector69 injector *webkit.ContentInjector
70 router *webkit.MessageRouter70 router *webkit.MessageRouter
71 settings *webkit.SettingsManager71 settings *webkit.SettingsManager
72- faviconCache *cache.FaviconCache72+ faviconAdapter *adapter.FaviconAdapter
73 73
74 // ID generator for tabs/panes74 // ID generator for tabs/panes
75 idCounter uint6475 idCounter uint64
@@ -214,7 +214,7 @@ func (a *App) onActivate(ctx context.Context) {
214 a.omniboxCfg = component.OmniboxConfig{214 a.omniboxCfg = component.OmniboxConfig{
215 HistoryUC: a.deps.HistoryUC,215 HistoryUC: a.deps.HistoryUC,
216 FavoritesUC: a.deps.FavoritesUC,216 FavoritesUC: a.deps.FavoritesUC,
217- FaviconCache: a.faviconCache,217+ FaviconAdapter: a.faviconAdapter,
218 CopyURLUC: a.deps.CopyURLUC,218 CopyURLUC: a.deps.CopyURLUC,
219 Shortcuts: a.deps.Config.SearchShortcuts,219 Shortcuts: a.deps.Config.SearchShortcuts,
220 DefaultSearch: a.deps.Config.DefaultSearchEngine,220 DefaultSearch: a.deps.Config.DefaultSearchEngine,
@@ -251,8 +251,8 @@ func (a *App) onShutdown(ctx context.Context) {
251 a.cancel(errors.New("application shutdown"))251 a.cancel(errors.New("application shutdown"))
252 252
253 // Cleanup resources253 // Cleanup resources
254- if a.faviconCache != nil {254+ if a.faviconAdapter != nil {
255- a.faviconCache.Close()255+ a.faviconAdapter.Close()
256 }256 }
257 if a.deps.Pool != nil {257 if a.deps.Pool != nil {
258 a.deps.Pool.Close(ctx)258 a.deps.Pool.Close(ctx)
@@ -271,19 +271,19 @@ func (a *App) initCoordinators(ctx context.Context) {
271 return a.activeWorkspace(), a.activeWorkspaceView()271 return a.activeWorkspace(), a.activeWorkspaceView()
272 }272 }
273 273
274- // Create FaviconCache with FaviconDatabase from WebKitContext274+ // Create FaviconAdapter with service and WebKit FaviconDatabase
275+ var faviconDB *webkit.FaviconDatabase
275 if a.deps.WebContext != nil {276 if a.deps.WebContext != nil {
276- a.faviconCache = cache.NewFaviconCache(a.deps.WebContext.FaviconDatabase())277+ faviconDB = a.deps.WebContext.FaviconDatabase()
277- } else {
278- a.faviconCache = cache.NewFaviconCache(nil)
279 }278 }
279+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
280 280
281 // 1. Content Coordinator (no dependencies on other coordinators)281 // 1. Content Coordinator (no dependencies on other coordinators)
282 a.contentCoord = coordinator.NewContentCoordinator(282 a.contentCoord = coordinator.NewContentCoordinator(
283 ctx,283 ctx,
284 a.pool,284 a.pool,
285 a.widgetFactory,285 a.widgetFactory,
286- a.faviconCache,286+ a.faviconAdapter,
287 getActiveWS,287 getActiveWS,
288 a.deps.ZoomUC,288 a.deps.ZoomUC,
289 )289 )
D internal/ui/cache/favicon_cache.go +0 -404
@@ -1,404 +0,0 @@
1-// Package cache provides in-memory caching utilities for the UI layer.
2-package cache
3-
4-import (
5- "context"
6- "fmt"
7- "io"
8- "net/http"
9- "net/url"
10- "os"
11- "path/filepath"
12- "sync"
13- "time"
14-
15- domainurl "github.com/bnema/dumber/internal/domain/url"
16- "github.com/bnema/dumber/internal/infrastructure/config"
17- "github.com/bnema/dumber/internal/logging"
18- "github.com/bnema/puregotk-webkit/webkit"
19- "github.com/jwijenbergh/puregotk/v4/gdk"
20- "github.com/jwijenbergh/puregotk/v4/gio"
21- "github.com/jwijenbergh/puregotk/v4/glib"
22-)
23-
24-const (
25- // DuckDuckGo favicon API URL template (better transparency support)
26- duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
27- // HTTP client timeout for favicon fetch
28- faviconFetchTimeout = 5 * time.Second
29- // diskWriteBufferSize defines the capacity of the favicon write channel
30- diskWriteBufferSize = 100
31- // File permissions for favicon cache
32- diskCacheDirPerm = 0750
33- diskCacheFilePerm = 0600
34-)
35-
36-// diskWrite represents a favicon to be written to disk asynchronously.
37-type diskWrite struct {
38- domain string
39- data []byte
40-}
41-
42-// FaviconCache provides a multi-tier favicon caching system:
43-// 1. In-memory cache keyed by domain for fast lookups
44-// 2. Disk cache for persistence across restarts
45-// 3. WebKit FaviconDatabase as backing store
46-// 4. DuckDuckGo favicon API as fallback
47-type FaviconCache struct {
48- mu sync.RWMutex
49- cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
50- faviconDB *webkit.FaviconDatabase
51- client *http.Client
52- diskCacheDir string // path to favicon cache directory
53- writeChan chan diskWrite // channel for async writes
54- closeOnce sync.Once // ensures Close is called only once
55-}
56-
57-// NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.
58-// The faviconDB can be nil, in which case only the in-memory cache is used.
59-func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {
60- // Get disk cache directory (fail silently if error)
61- diskCacheDir, _ := config.GetFaviconCacheDir()
62-
63- fc := &FaviconCache{
64- cache: make(map[string]*gdk.Texture),
65- faviconDB: faviconDB,
66- diskCacheDir: diskCacheDir,
67- writeChan: make(chan diskWrite, diskWriteBufferSize),
68- client: &http.Client{
69- Timeout: faviconFetchTimeout,
70- },
71- }
72-
73- // Start background writer goroutine
74- if diskCacheDir != "" {
75- go fc.diskWriter()
76- }
77-
78- return fc
79-}
80-
81-// Set stores a favicon texture for the given domain.
82-// Also queues a disk write if the favicon is not already cached on disk.
83-func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) {
84- if domain == "" || texture == nil {
85- return
86- }
87- fc.mu.Lock()
88- fc.cache[domain] = texture
89- fc.mu.Unlock()
90-
91- // Also save to disk if not already cached
92- fc.ensureDiskCache(domain)
93-}
94-
95-// ensureDiskCache fetches favicon from DuckDuckGo and saves to disk if not already cached.
96-func (fc *FaviconCache) ensureDiskCache(domain string) {
97- if fc.diskCacheDir == "" || domain == "" {
98- return
99- }
100-
101- // Check if already on disk
102- filename := domainurl.SanitizeDomainForFilename(domain)
103- path := filepath.Join(fc.diskCacheDir, filename)
104- if _, err := os.Stat(path); err == nil {
105- return // Already cached
106- }
107-
108- // Fetch from DuckDuckGo in background and save to disk
109- go func() {
110- ctx, cancel := context.WithTimeout(context.Background(), faviconFetchTimeout)
111- defer cancel()
112-
113- _, rawData := fc.fetchFromExternal(ctx, domain)
114- if len(rawData) > 0 {
115- fc.queueDiskWrite(domain, rawData)
116- }
117- }()
118-}
119-
120-// SetByURL stores a favicon texture for the domain extracted from the URL.
121-func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) {
122- domain := domainurl.ExtractDomain(pageURL)
123- fc.Set(domain, texture)
124-}
125-
126-// Get retrieves a favicon texture by domain from the in-memory cache.
127-// Returns nil if not found.
128-func (fc *FaviconCache) Get(domain string) *gdk.Texture {
129- if domain == "" {
130- return nil
131- }
132- fc.mu.RLock()
133- texture := fc.cache[domain]
134- fc.mu.RUnlock()
135- return texture
136-}
137-
138-// GetByURL retrieves a favicon texture by extracting the domain from the URL.
139-// Returns nil if not found in memory cache.
140-func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture {
141- domain := domainurl.ExtractDomain(pageURL)
142- return fc.Get(domain)
143-}
144-
145-// GetFromCacheByURL checks both memory and disk cache for a favicon.
146-// Returns nil if not found. Does not fetch from external sources.
147-// If found on disk, also populates memory cache.
148-func (fc *FaviconCache) GetFromCacheByURL(pageURL string) *gdk.Texture {
149- domain := domainurl.ExtractDomain(pageURL)
150- if domain == "" {
151- return nil
152- }
153-
154- // Check memory cache first
155- if texture := fc.Get(domain); texture != nil {
156- return texture
157- }
158-
159- // Check disk cache
160- if texture := fc.loadFromDisk(domain); texture != nil {
161- fc.Set(domain, texture)
162- return texture
163- }
164-
165- return nil
166-}
167-
168-// GetOrFetch checks the in-memory cache first, then queries the FaviconDatabase,
169-// and falls back to Google's favicon API if not found.
170-// The callback is invoked with the texture (or nil).
171-// This is safe to call from any goroutine; the callback runs on the GLib main loop.
172-func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
173- if callback == nil {
174- return
175- }
176-
177- log := logging.FromContext(ctx)
178- domain := domainurl.ExtractDomain(pageURL)
179-
180- // Check in-memory cache first
181- if texture := fc.Get(domain); texture != nil {
182- callback(texture)
183- return
184- }
185-
186- // Check disk cache second
187- if texture := fc.loadFromDisk(domain); texture != nil {
188- fc.Set(domain, texture)
189- callback(texture)
190- return
191- }
192-
193- // Helper to invoke callback on main thread
194- invokeCallback := func(texture *gdk.Texture) {
195- cb := glib.SourceFunc(func(uintptr) bool {
196- callback(texture)
197- return false
198- })
199- glib.IdleAdd(&cb, 0)
200- }
201-
202- // Helper to fetch from external API and invoke callback
203- fetchExternal := func() {
204- go func() {
205- texture, rawData := fc.fetchFromExternal(ctx, domain)
206- if texture != nil {
207- fc.Set(domain, texture)
208- // Queue async disk write
209- if len(rawData) > 0 {
210- fc.queueDiskWrite(domain, rawData)
211- }
212- }
213- invokeCallback(texture)
214- }()
215- }
216-
217- // No FaviconDatabase, fetch from external API directly
218- if fc.faviconDB == nil {
219- fetchExternal()
220- return
221- }
222-
223- // Try FaviconDatabase first (just once, no variant attempts)
224- asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
225- if resultPtr == 0 {
226- log.Debug().Str("domain", domain).Msg("favicon not in database, fetching from DuckDuckGo")
227- fetchExternal()
228- return
229- }
230-
231- result := &gio.AsyncResultBase{Ptr: resultPtr}
232- texture, err := fc.faviconDB.GetFaviconFinish(result)
233- if err != nil || texture == nil {
234- log.Debug().Str("domain", domain).Msg("favicon not in database, fetching from DuckDuckGo")
235- fetchExternal()
236- return
237- }
238-
239- // Found in database, cache and return
240- fc.Set(domain, texture)
241- invokeCallback(texture)
242- })
243-
244- // Try with the original URL
245- fc.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
246-}
247-
248-// fetchFromExternal fetches a favicon from DuckDuckGo's favicon API.
249-// Returns the texture and raw bytes (bytes used for disk cache).
250-func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) (texture *gdk.Texture, rawData []byte) {
251- log := logging.FromContext(ctx)
252-
253- if domain == "" {
254- return nil, nil
255- }
256-
257- faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
258- log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
259-
260- req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
261- if err != nil {
262- log.Debug().Err(err).Msg("failed to create favicon request")
263- return nil, nil
264- }
265-
266- resp, err := fc.client.Do(req)
267- if err != nil {
268- log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
269- return nil, nil
270- }
271- defer resp.Body.Close()
272-
273- if resp.StatusCode != http.StatusOK {
274- log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
275- return nil, nil
276- }
277-
278- data, err := io.ReadAll(resp.Body)
279- if err != nil {
280- log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
281- return nil, nil
282- }
283-
284- if len(data) == 0 {
285- log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
286- return nil, nil
287- }
288-
289- // Convert bytes to GdkTexture
290- bytes := glib.NewBytes(data, uint(len(data)))
291- if bytes == nil {
292- log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data")
293- return nil, nil
294- }
295-
296- texture, err = gdk.NewTextureFromBytes(bytes)
297- if err != nil {
298- log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes")
299- return nil, nil
300- }
301-
302- log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached")
303- return texture, data
304-}
305-
306-// Clear removes all entries from the in-memory cache.
307-func (fc *FaviconCache) Clear() {
308- fc.mu.Lock()
309- fc.cache = make(map[string]*gdk.Texture)
310- fc.mu.Unlock()
311-}
312-
313-// Size returns the number of entries in the in-memory cache.
314-func (fc *FaviconCache) Size() int {
315- fc.mu.RLock()
316- size := len(fc.cache)
317- fc.mu.RUnlock()
318- return size
319-}
320-
321-// Close stops the background writer goroutine.
322-func (fc *FaviconCache) Close() {
323- fc.closeOnce.Do(func() {
324- if fc.writeChan != nil {
325- close(fc.writeChan)
326- }
327- })
328-}
329-
330-// loadFromDisk attempts to load a favicon from disk cache.
331-// Returns nil if not found or on error.
332-func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture {
333- if fc.diskCacheDir == "" {
334- return nil
335- }
336-
337- filename := domainurl.SanitizeDomainForFilename(domain)
338- path := filepath.Join(fc.diskCacheDir, filename)
339-
340- //nolint:gosec // path is constructed from sanitized domain, not user input
341- data, err := os.ReadFile(path)
342- if err != nil {
343- return nil
344- }
345-
346- if len(data) == 0 {
347- return nil
348- }
349-
350- bytes := glib.NewBytes(data, uint(len(data)))
351- if bytes == nil {
352- return nil
353- }
354-
355- texture, err := gdk.NewTextureFromBytes(bytes)
356- if err != nil {
357- return nil
358- }
359-
360- return texture
361-}
362-
363-// writeToDisk atomically writes favicon data to disk.
364-func (fc *FaviconCache) writeToDisk(domain string, data []byte) {
365- if fc.diskCacheDir == "" || len(data) == 0 {
366- return
367- }
368-
369- // Ensure directory exists (lazy initialization)
370- if err := os.MkdirAll(fc.diskCacheDir, diskCacheDirPerm); err != nil {
371- return
372- }
373-
374- filename := domainurl.SanitizeDomainForFilename(domain)
375- finalPath := filepath.Join(fc.diskCacheDir, filename)
376- tempPath := finalPath + ".tmp"
377-
378- // Write to temp file
379- if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
380- return
381- }
382-
383- // Atomic rename
384- if err := os.Rename(tempPath, finalPath); err != nil {
385- _ = os.Remove(tempPath)
386- }
387-}
388-
389-// queueDiskWrite sends favicon data to be written asynchronously.
390-func (fc *FaviconCache) queueDiskWrite(domain string, data []byte) {
391- select {
392- case fc.writeChan <- diskWrite{domain: domain, data: data}:
393- // queued successfully
394- default:
395- // channel full, skip write
396- }
397-}
398-
399-// diskWriter processes async write requests.
400-func (fc *FaviconCache) diskWriter() {
401- for write := range fc.writeChan {
402- fc.writeToDisk(write.domain, write.data)
403- }
404-}
@@ -1,404 +0,0 @@
1-// Package cache provides in-memory caching utilities for the UI layer.
2-package cache
3-
4-import (
5- "context"
6- "fmt"
7- "io"
8- "net/http"
9- "net/url"
10- "os"
11- "path/filepath"
12- "sync"
13- "time"
14-
15- domainurl "github.com/bnema/dumber/internal/domain/url"
16- "github.com/bnema/dumber/internal/infrastructure/config"
17- "github.com/bnema/dumber/internal/logging"
18- "github.com/bnema/puregotk-webkit/webkit"
19- "github.com/jwijenbergh/puregotk/v4/gdk"
20- "github.com/jwijenbergh/puregotk/v4/gio"
21- "github.com/jwijenbergh/puregotk/v4/glib"
22-)
23-
24-const (
25- // DuckDuckGo favicon API URL template (better transparency support)
26- duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
27- // HTTP client timeout for favicon fetch
28- faviconFetchTimeout = 5 * time.Second
29- // diskWriteBufferSize defines the capacity of the favicon write channel
30- diskWriteBufferSize = 100
31- // File permissions for favicon cache
32- diskCacheDirPerm = 0750
33- diskCacheFilePerm = 0600
34-)
35-
36-// diskWrite represents a favicon to be written to disk asynchronously.
37-type diskWrite struct {
38- domain string
39- data []byte
40-}
41-
42-// FaviconCache provides a multi-tier favicon caching system:
43-// 1. In-memory cache keyed by domain for fast lookups
44-// 2. Disk cache for persistence across restarts
45-// 3. WebKit FaviconDatabase as backing store
46-// 4. DuckDuckGo favicon API as fallback
47-type FaviconCache struct {
48- mu sync.RWMutex
49- cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
50- faviconDB *webkit.FaviconDatabase
51- client *http.Client
52- diskCacheDir string // path to favicon cache directory
53- writeChan chan diskWrite // channel for async writes
54- closeOnce sync.Once // ensures Close is called only once
55-}
56-
57-// NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.
58-// The faviconDB can be nil, in which case only the in-memory cache is used.
59-func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {
60- // Get disk cache directory (fail silently if error)
61- diskCacheDir, _ := config.GetFaviconCacheDir()
62-
63- fc := &FaviconCache{
64- cache: make(map[string]*gdk.Texture),
65- faviconDB: faviconDB,
66- diskCacheDir: diskCacheDir,
67- writeChan: make(chan diskWrite, diskWriteBufferSize),
68- client: &http.Client{
69- Timeout: faviconFetchTimeout,
70- },
71- }
72-
73- // Start background writer goroutine
74- if diskCacheDir != "" {
75- go fc.diskWriter()
76- }
77-
78- return fc
79-}
80-
81-// Set stores a favicon texture for the given domain.
82-// Also queues a disk write if the favicon is not already cached on disk.
83-func (fc *FaviconCache) Set(domain string, texture *gdk.Texture) {
84- if domain == "" || texture == nil {
85- return
86- }
87- fc.mu.Lock()
88- fc.cache[domain] = texture
89- fc.mu.Unlock()
90-
91- // Also save to disk if not already cached
92- fc.ensureDiskCache(domain)
93-}
94-
95-// ensureDiskCache fetches favicon from DuckDuckGo and saves to disk if not already cached.
96-func (fc *FaviconCache) ensureDiskCache(domain string) {
97- if fc.diskCacheDir == "" || domain == "" {
98- return
99- }
100-
101- // Check if already on disk
102- filename := domainurl.SanitizeDomainForFilename(domain)
103- path := filepath.Join(fc.diskCacheDir, filename)
104- if _, err := os.Stat(path); err == nil {
105- return // Already cached
106- }
107-
108- // Fetch from DuckDuckGo in background and save to disk
109- go func() {
110- ctx, cancel := context.WithTimeout(context.Background(), faviconFetchTimeout)
111- defer cancel()
112-
113- _, rawData := fc.fetchFromExternal(ctx, domain)
114- if len(rawData) > 0 {
115- fc.queueDiskWrite(domain, rawData)
116- }
117- }()
118-}
119-
120-// SetByURL stores a favicon texture for the domain extracted from the URL.
121-func (fc *FaviconCache) SetByURL(pageURL string, texture *gdk.Texture) {
122- domain := domainurl.ExtractDomain(pageURL)
123- fc.Set(domain, texture)
124-}
125-
126-// Get retrieves a favicon texture by domain from the in-memory cache.
127-// Returns nil if not found.
128-func (fc *FaviconCache) Get(domain string) *gdk.Texture {
129- if domain == "" {
130- return nil
131- }
132- fc.mu.RLock()
133- texture := fc.cache[domain]
134- fc.mu.RUnlock()
135- return texture
136-}
137-
138-// GetByURL retrieves a favicon texture by extracting the domain from the URL.
139-// Returns nil if not found in memory cache.
140-func (fc *FaviconCache) GetByURL(pageURL string) *gdk.Texture {
141- domain := domainurl.ExtractDomain(pageURL)
142- return fc.Get(domain)
143-}
144-
145-// GetFromCacheByURL checks both memory and disk cache for a favicon.
146-// Returns nil if not found. Does not fetch from external sources.
147-// If found on disk, also populates memory cache.
148-func (fc *FaviconCache) GetFromCacheByURL(pageURL string) *gdk.Texture {
149- domain := domainurl.ExtractDomain(pageURL)
150- if domain == "" {
151- return nil
152- }
153-
154- // Check memory cache first
155- if texture := fc.Get(domain); texture != nil {
156- return texture
157- }
158-
159- // Check disk cache
160- if texture := fc.loadFromDisk(domain); texture != nil {
161- fc.Set(domain, texture)
162- return texture
163- }
164-
165- return nil
166-}
167-
168-// GetOrFetch checks the in-memory cache first, then queries the FaviconDatabase,
169-// and falls back to Google's favicon API if not found.
170-// The callback is invoked with the texture (or nil).
171-// This is safe to call from any goroutine; the callback runs on the GLib main loop.
172-func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
173- if callback == nil {
174- return
175- }
176-
177- log := logging.FromContext(ctx)
178- domain := domainurl.ExtractDomain(pageURL)
179-
180- // Check in-memory cache first
181- if texture := fc.Get(domain); texture != nil {
182- callback(texture)
183- return
184- }
185-
186- // Check disk cache second
187- if texture := fc.loadFromDisk(domain); texture != nil {
188- fc.Set(domain, texture)
189- callback(texture)
190- return
191- }
192-
193- // Helper to invoke callback on main thread
194- invokeCallback := func(texture *gdk.Texture) {
195- cb := glib.SourceFunc(func(uintptr) bool {
196- callback(texture)
197- return false
198- })
199- glib.IdleAdd(&cb, 0)
200- }
201-
202- // Helper to fetch from external API and invoke callback
203- fetchExternal := func() {
204- go func() {
205- texture, rawData := fc.fetchFromExternal(ctx, domain)
206- if texture != nil {
207- fc.Set(domain, texture)
208- // Queue async disk write
209- if len(rawData) > 0 {
210- fc.queueDiskWrite(domain, rawData)
211- }
212- }
213- invokeCallback(texture)
214- }()
215- }
216-
217- // No FaviconDatabase, fetch from external API directly
218- if fc.faviconDB == nil {
219- fetchExternal()
220- return
221- }
222-
223- // Try FaviconDatabase first (just once, no variant attempts)
224- asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
225- if resultPtr == 0 {
226- log.Debug().Str("domain", domain).Msg("favicon not in database, fetching from DuckDuckGo")
227- fetchExternal()
228- return
229- }
230-
231- result := &gio.AsyncResultBase{Ptr: resultPtr}
232- texture, err := fc.faviconDB.GetFaviconFinish(result)
233- if err != nil || texture == nil {
234- log.Debug().Str("domain", domain).Msg("favicon not in database, fetching from DuckDuckGo")
235- fetchExternal()
236- return
237- }
238-
239- // Found in database, cache and return
240- fc.Set(domain, texture)
241- invokeCallback(texture)
242- })
243-
244- // Try with the original URL
245- fc.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
246-}
247-
248-// fetchFromExternal fetches a favicon from DuckDuckGo's favicon API.
249-// Returns the texture and raw bytes (bytes used for disk cache).
250-func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) (texture *gdk.Texture, rawData []byte) {
251- log := logging.FromContext(ctx)
252-
253- if domain == "" {
254- return nil, nil
255- }
256-
257- faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
258- log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
259-
260- req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
261- if err != nil {
262- log.Debug().Err(err).Msg("failed to create favicon request")
263- return nil, nil
264- }
265-
266- resp, err := fc.client.Do(req)
267- if err != nil {
268- log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
269- return nil, nil
270- }
271- defer resp.Body.Close()
272-
273- if resp.StatusCode != http.StatusOK {
274- log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
275- return nil, nil
276- }
277-
278- data, err := io.ReadAll(resp.Body)
279- if err != nil {
280- log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
281- return nil, nil
282- }
283-
284- if len(data) == 0 {
285- log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
286- return nil, nil
287- }
288-
289- // Convert bytes to GdkTexture
290- bytes := glib.NewBytes(data, uint(len(data)))
291- if bytes == nil {
292- log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data")
293- return nil, nil
294- }
295-
296- texture, err = gdk.NewTextureFromBytes(bytes)
297- if err != nil {
298- log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes")
299- return nil, nil
300- }
301-
302- log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached")
303- return texture, data
304-}
305-
306-// Clear removes all entries from the in-memory cache.
307-func (fc *FaviconCache) Clear() {
308- fc.mu.Lock()
309- fc.cache = make(map[string]*gdk.Texture)
310- fc.mu.Unlock()
311-}
312-
313-// Size returns the number of entries in the in-memory cache.
314-func (fc *FaviconCache) Size() int {
315- fc.mu.RLock()
316- size := len(fc.cache)
317- fc.mu.RUnlock()
318- return size
319-}
320-
321-// Close stops the background writer goroutine.
322-func (fc *FaviconCache) Close() {
323- fc.closeOnce.Do(func() {
324- if fc.writeChan != nil {
325- close(fc.writeChan)
326- }
327- })
328-}
329-
330-// loadFromDisk attempts to load a favicon from disk cache.
331-// Returns nil if not found or on error.
332-func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture {
333- if fc.diskCacheDir == "" {
334- return nil
335- }
336-
337- filename := domainurl.SanitizeDomainForFilename(domain)
338- path := filepath.Join(fc.diskCacheDir, filename)
339-
340- //nolint:gosec // path is constructed from sanitized domain, not user input
341- data, err := os.ReadFile(path)
342- if err != nil {
343- return nil
344- }
345-
346- if len(data) == 0 {
347- return nil
348- }
349-
350- bytes := glib.NewBytes(data, uint(len(data)))
351- if bytes == nil {
352- return nil
353- }
354-
355- texture, err := gdk.NewTextureFromBytes(bytes)
356- if err != nil {
357- return nil
358- }
359-
360- return texture
361-}
362-
363-// writeToDisk atomically writes favicon data to disk.
364-func (fc *FaviconCache) writeToDisk(domain string, data []byte) {
365- if fc.diskCacheDir == "" || len(data) == 0 {
366- return
367- }
368-
369- // Ensure directory exists (lazy initialization)
370- if err := os.MkdirAll(fc.diskCacheDir, diskCacheDirPerm); err != nil {
371- return
372- }
373-
374- filename := domainurl.SanitizeDomainForFilename(domain)
375- finalPath := filepath.Join(fc.diskCacheDir, filename)
376- tempPath := finalPath + ".tmp"
377-
378- // Write to temp file
379- if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
380- return
381- }
382-
383- // Atomic rename
384- if err := os.Rename(tempPath, finalPath); err != nil {
385- _ = os.Remove(tempPath)
386- }
387-}
388-
389-// queueDiskWrite sends favicon data to be written asynchronously.
390-func (fc *FaviconCache) queueDiskWrite(domain string, data []byte) {
391- select {
392- case fc.writeChan <- diskWrite{domain: domain, data: data}:
393- // queued successfully
394- default:
395- // channel full, skip write
396- }
397-}
398-
399-// diskWriter processes async write requests.
400-func (fc *FaviconCache) diskWriter() {
401- for write := range fc.writeChan {
402- fc.writeToDisk(write.domain, write.data)
403- }
404-}
M internal/ui/component/omnibox.go +6 -6
@@ -12,7 +12,7 @@ import (
1212 "github.com/bnema/dumber/internal/domain/url"
1313 "github.com/bnema/dumber/internal/infrastructure/config"
1414 "github.com/bnema/dumber/internal/logging"
15- "github.com/bnema/dumber/internal/ui/cache"
15+ "github.com/bnema/dumber/internal/ui/adapter"
1616 "github.com/bnema/dumber/internal/ui/layout"
1717 "github.com/jwijenbergh/puregotk/v4/gdk"
1818 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -79,7 +79,7 @@ type Omnibox struct {
7979 // Dependencies
8080 historyUC *usecase.SearchHistoryUseCase
8181 favoritesUC *usecase.ManageFavoritesUseCase
82- faviconCache *cache.FaviconCache
82+ faviconAdapter *adapter.FaviconAdapter
8383 copyURLUC *usecase.CopyURLUseCase
8484 shortcuts map[string]config.SearchShortcut
8585 defaultSearch string
@@ -112,7 +112,7 @@ type Omnibox struct {
112112 type OmniboxConfig struct {
113113 HistoryUC *usecase.SearchHistoryUseCase
114114 FavoritesUC *usecase.ManageFavoritesUseCase
115- FaviconCache *cache.FaviconCache
115+ FaviconAdapter *adapter.FaviconAdapter
116116 CopyURLUC *usecase.CopyURLUseCase
117117 Shortcuts map[string]config.SearchShortcut
118118 DefaultSearch string
@@ -137,7 +137,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
137137 selectedIndex: -1,
138138 historyUC: cfg.HistoryUC,
139139 favoritesUC: cfg.FavoritesUC,
140- faviconCache: cfg.FaviconCache,
140+ faviconAdapter: cfg.FaviconAdapter,
141141 copyURLUC: cfg.CopyURLUC,
142142 shortcuts: cfg.Shortcuts,
143143 defaultSearch: cfg.DefaultSearch,
@@ -769,8 +769,8 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image {
769769 favicon.AddCssClass("omnibox-favicon")
770770
771771 // Async load favicon from cache
772- if o.faviconCache != nil && url != "" {
773- o.faviconCache.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {
772+ if o.faviconAdapter != nil && url != "" {
773+ o.faviconAdapter.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {
774774 if texture != nil {
775775 var cb glib.SourceFunc = func(data uintptr) bool {
776776 favicon.SetFromPaintable(texture)
@@ -12,7 +12,7 @@ import (
12 "github.com/bnema/dumber/internal/domain/url"12 "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15- "github.com/bnema/dumber/internal/ui/cache"15+ "github.com/bnema/dumber/internal/ui/adapter"
16 "github.com/bnema/dumber/internal/ui/layout"16 "github.com/bnema/dumber/internal/ui/layout"
17 "github.com/jwijenbergh/puregotk/v4/gdk"17 "github.com/jwijenbergh/puregotk/v4/gdk"
18 "github.com/jwijenbergh/puregotk/v4/glib"18 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -79,7 +79,7 @@ type Omnibox struct {
79 // Dependencies79 // Dependencies
80 historyUC *usecase.SearchHistoryUseCase80 historyUC *usecase.SearchHistoryUseCase
81 favoritesUC *usecase.ManageFavoritesUseCase81 favoritesUC *usecase.ManageFavoritesUseCase
82- faviconCache *cache.FaviconCache82+ faviconAdapter *adapter.FaviconAdapter
83 copyURLUC *usecase.CopyURLUseCase83 copyURLUC *usecase.CopyURLUseCase
84 shortcuts map[string]config.SearchShortcut84 shortcuts map[string]config.SearchShortcut
85 defaultSearch string85 defaultSearch string
@@ -112,7 +112,7 @@ type Omnibox struct {
112 type OmniboxConfig struct {112 type OmniboxConfig struct {
113 HistoryUC *usecase.SearchHistoryUseCase113 HistoryUC *usecase.SearchHistoryUseCase
114 FavoritesUC *usecase.ManageFavoritesUseCase114 FavoritesUC *usecase.ManageFavoritesUseCase
115- FaviconCache *cache.FaviconCache115+ FaviconAdapter *adapter.FaviconAdapter
116 CopyURLUC *usecase.CopyURLUseCase116 CopyURLUC *usecase.CopyURLUseCase
117 Shortcuts map[string]config.SearchShortcut117 Shortcuts map[string]config.SearchShortcut
118 DefaultSearch string118 DefaultSearch string
@@ -137,7 +137,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
137 selectedIndex: -1,137 selectedIndex: -1,
138 historyUC: cfg.HistoryUC,138 historyUC: cfg.HistoryUC,
139 favoritesUC: cfg.FavoritesUC,139 favoritesUC: cfg.FavoritesUC,
140- faviconCache: cfg.FaviconCache,140+ faviconAdapter: cfg.FaviconAdapter,
141 copyURLUC: cfg.CopyURLUC,141 copyURLUC: cfg.CopyURLUC,
142 shortcuts: cfg.Shortcuts,142 shortcuts: cfg.Shortcuts,
143 defaultSearch: cfg.DefaultSearch,143 defaultSearch: cfg.DefaultSearch,
@@ -769,8 +769,8 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image {
769 favicon.AddCssClass("omnibox-favicon")769 favicon.AddCssClass("omnibox-favicon")
770 770
771 // Async load favicon from cache771 // Async load favicon from cache
772- if o.faviconCache != nil && url != "" {772+ if o.faviconAdapter != nil && url != "" {
773- o.faviconCache.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {773+ o.faviconAdapter.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {
774 if texture != nil {774 if texture != nil {
775 var cb glib.SourceFunc = func(data uintptr) bool {775 var cb glib.SourceFunc = func(data uintptr) bool {
776 favicon.SetFromPaintable(texture)776 favicon.SetFromPaintable(texture)
M internal/ui/coordinator/content.go +23 -29
@@ -12,7 +12,7 @@ import (
1212 "github.com/bnema/dumber/internal/infrastructure/config"
1313 "github.com/bnema/dumber/internal/infrastructure/webkit"
1414 "github.com/bnema/dumber/internal/logging"
15- "github.com/bnema/dumber/internal/ui/cache"
15+ "github.com/bnema/dumber/internal/ui/adapter"
1616 "github.com/bnema/dumber/internal/ui/component"
1717 "github.com/bnema/dumber/internal/ui/input"
1818 "github.com/bnema/dumber/internal/ui/layout"
@@ -21,10 +21,10 @@ import (
2121
2222 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
2323 type ContentCoordinator struct {
24- pool *webkit.WebViewPool
25- widgetFactory layout.WidgetFactory
26- faviconCache *cache.FaviconCache
27- zoomUC *usecase.ManageZoomUseCase
24+ pool *webkit.WebViewPool
25+ widgetFactory layout.WidgetFactory
26+ faviconAdapter *adapter.FaviconAdapter
27+ zoomUC *usecase.ManageZoomUseCase
2828
2929 webViews map[entity.PaneID]*webkit.WebView
3030 paneTitles map[entity.PaneID]string
@@ -68,7 +68,7 @@ func NewContentCoordinator(
6868 ctx context.Context,
6969 pool *webkit.WebViewPool,
7070 widgetFactory layout.WidgetFactory,
71- faviconCache *cache.FaviconCache,
71+ faviconAdapter *adapter.FaviconAdapter,
7272 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
7373 zoomUC *usecase.ManageZoomUseCase,
7474 ) *ContentCoordinator {
@@ -76,15 +76,15 @@ func NewContentCoordinator(
7676 log.Debug().Msg("creating content coordinator")
7777
7878 return &ContentCoordinator{
79- pool: pool,
80- widgetFactory: widgetFactory,
81- faviconCache: faviconCache,
82- zoomUC: zoomUC,
83- webViews: make(map[entity.PaneID]*webkit.WebView),
84- paneTitles: make(map[entity.PaneID]string),
85- navOrigins: make(map[entity.PaneID]string),
86- getActiveWS: getActiveWS,
87- pendingPopups: make(map[port.WebViewID]*PendingPopup),
79+ pool: pool,
80+ widgetFactory: widgetFactory,
81+ faviconAdapter: faviconAdapter,
82+ zoomUC: zoomUC,
83+ webViews: make(map[entity.PaneID]*webkit.WebView),
84+ paneTitles: make(map[entity.PaneID]string),
85+ navOrigins: make(map[entity.PaneID]string),
86+ getActiveWS: getActiveWS,
87+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
8888 }
8989 }
9090
@@ -386,18 +386,12 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
386386 }
387387 uri := wv.URI()
388388
389- // Update favicon cache with domain key (final URL after redirects)
390- if c.faviconCache != nil && favicon != nil && uri != "" {
391- c.faviconCache.SetByURL(uri, favicon)
392-
393- // Also cache under original navigation URL to handle cross-domain redirects
394- // e.g., google.fr → google.com: cache favicon under both domains
389+ // Update favicon cache with domain key (handles cross-domain redirects)
390+ if c.faviconAdapter != nil && favicon != nil && uri != "" {
395391 c.navOriginMu.RLock()
396392 originURL := c.navOrigins[paneID]
397393 c.navOriginMu.RUnlock()
398- if originURL != "" && originURL != uri {
399- c.faviconCache.SetByURL(originURL, favicon)
400- }
394+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
401395 }
402396
403397 // Update StackedView favicon if this pane is in a stack
@@ -445,9 +439,9 @@ func (c *ContentCoordinator) updateStackedPaneFavicon(ctx context.Context, ws *e
445439 }
446440 }
447441
448-// FaviconCache returns the favicon cache for external use (e.g., omnibox).
449-func (c *ContentCoordinator) FaviconCache() *cache.FaviconCache {
450- return c.faviconCache
442+// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
443+func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
444+ return c.faviconAdapter
451445 }
452446
453447 // SetNavigationOrigin records the original URL before navigation starts.
@@ -463,12 +457,12 @@ func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url strin
463457 // title bar immediately if a cached favicon exists for the URL.
464458 // This provides instant favicon display without waiting for WebKit.
465459 func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
466- if c.faviconCache == nil || url == "" {
460+ if c.faviconAdapter == nil || url == "" {
467461 return
468462 }
469463
470464 // Check memory and disk cache (no external fetch)
471- texture := c.faviconCache.GetFromCacheByURL(url)
465+ texture := c.faviconAdapter.PreloadFromCache(url)
472466 if texture == nil {
473467 return
474468 }
@@ -12,7 +12,7 @@ import (
12 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
13 "github.com/bnema/dumber/internal/infrastructure/webkit"13 "github.com/bnema/dumber/internal/infrastructure/webkit"
14 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15- "github.com/bnema/dumber/internal/ui/cache"15+ "github.com/bnema/dumber/internal/ui/adapter"
16 "github.com/bnema/dumber/internal/ui/component"16 "github.com/bnema/dumber/internal/ui/component"
17 "github.com/bnema/dumber/internal/ui/input"17 "github.com/bnema/dumber/internal/ui/input"
18 "github.com/bnema/dumber/internal/ui/layout"18 "github.com/bnema/dumber/internal/ui/layout"
@@ -21,10 +21,10 @@ import (
21 21
22 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.22 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
23 type ContentCoordinator struct {23 type ContentCoordinator struct {
24- pool *webkit.WebViewPool24+ pool *webkit.WebViewPool
25- widgetFactory layout.WidgetFactory25+ widgetFactory layout.WidgetFactory
26- faviconCache *cache.FaviconCache26+ faviconAdapter *adapter.FaviconAdapter
27- zoomUC *usecase.ManageZoomUseCase27+ zoomUC *usecase.ManageZoomUseCase
28 28
29 webViews map[entity.PaneID]*webkit.WebView29 webViews map[entity.PaneID]*webkit.WebView
30 paneTitles map[entity.PaneID]string30 paneTitles map[entity.PaneID]string
@@ -68,7 +68,7 @@ func NewContentCoordinator(
68 ctx context.Context,68 ctx context.Context,
69 pool *webkit.WebViewPool,69 pool *webkit.WebViewPool,
70 widgetFactory layout.WidgetFactory,70 widgetFactory layout.WidgetFactory,
71- faviconCache *cache.FaviconCache,71+ faviconAdapter *adapter.FaviconAdapter,
72 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),72 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
73 zoomUC *usecase.ManageZoomUseCase,73 zoomUC *usecase.ManageZoomUseCase,
74 ) *ContentCoordinator {74 ) *ContentCoordinator {
@@ -76,15 +76,15 @@ func NewContentCoordinator(
76 log.Debug().Msg("creating content coordinator")76 log.Debug().Msg("creating content coordinator")
77 77
78 return &ContentCoordinator{78 return &ContentCoordinator{
79- pool: pool,79+ pool: pool,
80- widgetFactory: widgetFactory,80+ widgetFactory: widgetFactory,
81- faviconCache: faviconCache,81+ faviconAdapter: faviconAdapter,
82- zoomUC: zoomUC,82+ zoomUC: zoomUC,
83- webViews: make(map[entity.PaneID]*webkit.WebView),83+ webViews: make(map[entity.PaneID]*webkit.WebView),
84- paneTitles: make(map[entity.PaneID]string),84+ paneTitles: make(map[entity.PaneID]string),
85- navOrigins: make(map[entity.PaneID]string),85+ navOrigins: make(map[entity.PaneID]string),
86- getActiveWS: getActiveWS,86+ getActiveWS: getActiveWS,
87- pendingPopups: make(map[port.WebViewID]*PendingPopup),87+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
88 }88 }
89 }89 }
90 90
@@ -386,18 +386,12 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
386 }386 }
387 uri := wv.URI()387 uri := wv.URI()
388 388
389- // Update favicon cache with domain key (final URL after redirects)389+ // Update favicon cache with domain key (handles cross-domain redirects)
390- if c.faviconCache != nil && favicon != nil && uri != "" {390+ if c.faviconAdapter != nil && favicon != nil && uri != "" {
391- c.faviconCache.SetByURL(uri, favicon)
392-
393- // Also cache under original navigation URL to handle cross-domain redirects
394- // e.g., google.fr → google.com: cache favicon under both domains
395 c.navOriginMu.RLock()391 c.navOriginMu.RLock()
396 originURL := c.navOrigins[paneID]392 originURL := c.navOrigins[paneID]
397 c.navOriginMu.RUnlock()393 c.navOriginMu.RUnlock()
398- if originURL != "" && originURL != uri {394+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
399- c.faviconCache.SetByURL(originURL, favicon)
400- }
401 }395 }
402 396
403 // Update StackedView favicon if this pane is in a stack397 // Update StackedView favicon if this pane is in a stack
@@ -445,9 +439,9 @@ func (c *ContentCoordinator) updateStackedPaneFavicon(ctx context.Context, ws *e
445 }439 }
446 }440 }
447 441
448-// FaviconCache returns the favicon cache for external use (e.g., omnibox).442+// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
449-func (c *ContentCoordinator) FaviconCache() *cache.FaviconCache {443+func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
450- return c.faviconCache444+ return c.faviconAdapter
451 }445 }
452 446
453 // SetNavigationOrigin records the original URL before navigation starts.447 // SetNavigationOrigin records the original URL before navigation starts.
@@ -463,12 +457,12 @@ func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url strin
463 // title bar immediately if a cached favicon exists for the URL.457 // title bar immediately if a cached favicon exists for the URL.
464 // This provides instant favicon display without waiting for WebKit.458 // This provides instant favicon display without waiting for WebKit.
465 func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {459 func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
466- if c.faviconCache == nil || url == "" {460+ if c.faviconAdapter == nil || url == "" {
467 return461 return
468 }462 }
469 463
470 // Check memory and disk cache (no external fetch)464 // Check memory and disk cache (no external fetch)
471- texture := c.faviconCache.GetFromCacheByURL(url)465+ texture := c.faviconAdapter.PreloadFromCache(url)
472 if texture == nil {466 if texture == nil {
473 return467 return
474 }468 }
M internal/ui/deps.go +3 -1
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/repository"
1010 "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1112 "github.com/bnema/dumber/internal/infrastructure/webkit"
1213 "github.com/bnema/dumber/internal/ui/theme"
1314 )
@@ -47,7 +48,8 @@ type Dependencies struct {
4748 CopyURLUC *usecase.CopyURLUseCase
4849
4950 // Infrastructure Adapters
50- Clipboard port.Clipboard
51+ Clipboard port.Clipboard
52+ FaviconService *favicon.Service
5153 }
5254
5355 // Validate checks that all required dependencies are set.
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/repository"9 "github.com/bnema/dumber/internal/domain/repository"
10 "github.com/bnema/dumber/internal/infrastructure/config"10 "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/favicon"
11 "github.com/bnema/dumber/internal/infrastructure/webkit"12 "github.com/bnema/dumber/internal/infrastructure/webkit"
12 "github.com/bnema/dumber/internal/ui/theme"13 "github.com/bnema/dumber/internal/ui/theme"
13 )14 )
@@ -47,7 +48,8 @@ type Dependencies struct {
47 CopyURLUC *usecase.CopyURLUseCase48 CopyURLUC *usecase.CopyURLUseCase
48 49
49 // Infrastructure Adapters50 // Infrastructure Adapters
50- Clipboard port.Clipboard51+ Clipboard port.Clipboard
52+ FaviconService *favicon.Service
51 }53 }
52 54
53 // Validate checks that all required dependencies are set.55 // Validate checks that all required dependencies are set.

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