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 ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/domain/build" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 18 | 19 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| @@ -183,6 +184,10 @@ func runGUI() { | |||
| 183 | 184 | clipboardAdapter := clipboard.New() | |
| 184 | 185 | copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter) | |
| 185 | 186 | ||
| 187 | + | // Create favicon service | |
| 188 | + | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| 189 | + | faviconService := favicon.NewService(faviconCacheDir) | |
| 190 | + | ||
| 186 | 191 | // Build dependencies | |
| 187 | 192 | deps := &ui.Dependencies{ | |
| 188 | 193 | Ctx: ctx, | |
| @@ -207,7 +212,8 @@ func runGUI() { | |||
| 207 | 212 | NavigateUC: navigateUC, | |
| 208 | 213 | CopyURLUC: copyURLUC, | |
| 209 | 214 | // Infrastructure Adapters | |
| 210 | - | Clipboard: clipboardAdapter, | |
| 215 | + | Clipboard: clipboardAdapter, | |
| 216 | + | FaviconService: faviconService, | |
| 211 | 217 | } | |
| 212 | 218 | ||
| 213 | 219 | // 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 dependencies | 191 | // 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 Adapters | 214 | // Infrastructure Adapters | ||
| 210 | - | Clipboard: clipboardAdapter, | 215 | + | Clipboard: clipboardAdapter, |
| 216 | + | FaviconService: faviconService, | |||
| 211 | } | 217 | } | ||
| 212 | 218 | ||||
| 213 | // Run the application | 219 | // 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 ( | |||
| 11 | 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 | 18 | // WebKitContext manages the shared WebContext and persistent NetworkSession. | |
| 15 | 19 | // IMPORTANT: This MUST be initialized before creating any WebViews. | |
| 16 | 20 | 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 ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers" | |
| 17 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 18 | - | "github.com/bnema/dumber/internal/ui/cache" | |
| 18 | + | "github.com/bnema/dumber/internal/ui/adapter" | |
| 19 | 19 | "github.com/bnema/dumber/internal/ui/component" | |
| 20 | 20 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 21 | 21 | "github.com/bnema/dumber/internal/ui/dispatcher" | |
| @@ -69,7 +69,7 @@ type App struct { | |||
| 69 | 69 | injector *webkit.ContentInjector | |
| 70 | 70 | router *webkit.MessageRouter | |
| 71 | 71 | settings *webkit.SettingsManager | |
| 72 | - | faviconCache *cache.FaviconCache | |
| 72 | + | faviconAdapter *adapter.FaviconAdapter | |
| 73 | 73 | ||
| 74 | 74 | // ID generator for tabs/panes | |
| 75 | 75 | idCounter uint64 | |
| @@ -214,7 +214,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 214 | 214 | a.omniboxCfg = component.OmniboxConfig{ | |
| 215 | 215 | HistoryUC: a.deps.HistoryUC, | |
| 216 | 216 | FavoritesUC: a.deps.FavoritesUC, | |
| 217 | - | FaviconCache: a.faviconCache, | |
| 217 | + | FaviconAdapter: a.faviconAdapter, | |
| 218 | 218 | CopyURLUC: a.deps.CopyURLUC, | |
| 219 | 219 | Shortcuts: a.deps.Config.SearchShortcuts, | |
| 220 | 220 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | |
| @@ -251,8 +251,8 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 251 | 251 | a.cancel(errors.New("application shutdown")) | |
| 252 | 252 | ||
| 253 | 253 | // Cleanup resources | |
| 254 | - | if a.faviconCache != nil { | |
| 255 | - | a.faviconCache.Close() | |
| 254 | + | if a.faviconAdapter != nil { | |
| 255 | + | a.faviconAdapter.Close() | |
| 256 | 256 | } | |
| 257 | 257 | if a.deps.Pool != nil { | |
| 258 | 258 | a.deps.Pool.Close(ctx) | |
| @@ -271,19 +271,19 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 271 | 271 | return a.activeWorkspace(), a.activeWorkspaceView() | |
| 272 | 272 | } | |
| 273 | 273 | ||
| 274 | - | // Create FaviconCache with FaviconDatabase from WebKitContext | |
| 274 | + | // Create FaviconAdapter with service and WebKit FaviconDatabase | |
| 275 | + | var faviconDB *webkit.FaviconDatabase | |
| 275 | 276 | 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() | |
| 279 | 278 | } | |
| 279 | + | a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB) | |
| 280 | 280 | ||
| 281 | 281 | // 1. Content Coordinator (no dependencies on other coordinators) | |
| 282 | 282 | a.contentCoord = coordinator.NewContentCoordinator( | |
| 283 | 283 | ctx, | |
| 284 | 284 | a.pool, | |
| 285 | 285 | a.widgetFactory, | |
| 286 | - | a.faviconCache, | |
| 286 | + | a.faviconAdapter, | |
| 287 | 287 | getActiveWS, | |
| 288 | 288 | a.deps.ZoomUC, | |
| 289 | 289 | ) | |
| @@ -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.ContentInjector | 69 | injector *webkit.ContentInjector | ||
| 70 | router *webkit.MessageRouter | 70 | router *webkit.MessageRouter | ||
| 71 | settings *webkit.SettingsManager | 71 | settings *webkit.SettingsManager | ||
| 72 | - | faviconCache *cache.FaviconCache | 72 | + | faviconAdapter *adapter.FaviconAdapter |
| 73 | 73 | ||||
| 74 | // ID generator for tabs/panes | 74 | // ID generator for tabs/panes | ||
| 75 | idCounter uint64 | 75 | 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 resources | 253 | // 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 WebKitContext | 274 | + | // 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 ( | |||
| 12 | 12 | "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | - | "github.com/bnema/dumber/internal/ui/cache" | |
| 15 | + | "github.com/bnema/dumber/internal/ui/adapter" | |
| 16 | 16 | "github.com/bnema/dumber/internal/ui/layout" | |
| 17 | 17 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 18 | 18 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -79,7 +79,7 @@ type Omnibox struct { | |||
| 79 | 79 | // Dependencies | |
| 80 | 80 | historyUC *usecase.SearchHistoryUseCase | |
| 81 | 81 | favoritesUC *usecase.ManageFavoritesUseCase | |
| 82 | - | faviconCache *cache.FaviconCache | |
| 82 | + | faviconAdapter *adapter.FaviconAdapter | |
| 83 | 83 | copyURLUC *usecase.CopyURLUseCase | |
| 84 | 84 | shortcuts map[string]config.SearchShortcut | |
| 85 | 85 | defaultSearch string | |
| @@ -112,7 +112,7 @@ type Omnibox struct { | |||
| 112 | 112 | type OmniboxConfig struct { | |
| 113 | 113 | HistoryUC *usecase.SearchHistoryUseCase | |
| 114 | 114 | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 115 | - | FaviconCache *cache.FaviconCache | |
| 115 | + | FaviconAdapter *adapter.FaviconAdapter | |
| 116 | 116 | CopyURLUC *usecase.CopyURLUseCase | |
| 117 | 117 | Shortcuts map[string]config.SearchShortcut | |
| 118 | 118 | DefaultSearch string | |
| @@ -137,7 +137,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 137 | 137 | selectedIndex: -1, | |
| 138 | 138 | historyUC: cfg.HistoryUC, | |
| 139 | 139 | favoritesUC: cfg.FavoritesUC, | |
| 140 | - | faviconCache: cfg.FaviconCache, | |
| 140 | + | faviconAdapter: cfg.FaviconAdapter, | |
| 141 | 141 | copyURLUC: cfg.CopyURLUC, | |
| 142 | 142 | shortcuts: cfg.Shortcuts, | |
| 143 | 143 | defaultSearch: cfg.DefaultSearch, | |
| @@ -769,8 +769,8 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image { | |||
| 769 | 769 | favicon.AddCssClass("omnibox-favicon") | |
| 770 | 770 | ||
| 771 | 771 | // 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) { | |
| 774 | 774 | if texture != nil { | |
| 775 | 775 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 776 | 776 | 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 | // Dependencies | 79 | // Dependencies | ||
| 80 | historyUC *usecase.SearchHistoryUseCase | 80 | historyUC *usecase.SearchHistoryUseCase | ||
| 81 | favoritesUC *usecase.ManageFavoritesUseCase | 81 | favoritesUC *usecase.ManageFavoritesUseCase | ||
| 82 | - | faviconCache *cache.FaviconCache | 82 | + | faviconAdapter *adapter.FaviconAdapter |
| 83 | copyURLUC *usecase.CopyURLUseCase | 83 | copyURLUC *usecase.CopyURLUseCase | ||
| 84 | shortcuts map[string]config.SearchShortcut | 84 | shortcuts map[string]config.SearchShortcut | ||
| 85 | defaultSearch string | 85 | defaultSearch string | ||
| @@ -112,7 +112,7 @@ type Omnibox struct { | |||||
| 112 | type OmniboxConfig struct { | 112 | type OmniboxConfig struct { | ||
| 113 | HistoryUC *usecase.SearchHistoryUseCase | 113 | HistoryUC *usecase.SearchHistoryUseCase | ||
| 114 | FavoritesUC *usecase.ManageFavoritesUseCase | 114 | FavoritesUC *usecase.ManageFavoritesUseCase | ||
| 115 | - | FaviconCache *cache.FaviconCache | 115 | + | FaviconAdapter *adapter.FaviconAdapter |
| 116 | CopyURLUC *usecase.CopyURLUseCase | 116 | CopyURLUC *usecase.CopyURLUseCase | ||
| 117 | Shortcuts map[string]config.SearchShortcut | 117 | Shortcuts map[string]config.SearchShortcut | ||
| 118 | DefaultSearch string | 118 | 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 cache | 771 | // 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 ( | |||
| 12 | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 14 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | - | "github.com/bnema/dumber/internal/ui/cache" | |
| 15 | + | "github.com/bnema/dumber/internal/ui/adapter" | |
| 16 | 16 | "github.com/bnema/dumber/internal/ui/component" | |
| 17 | 17 | "github.com/bnema/dumber/internal/ui/input" | |
| 18 | 18 | "github.com/bnema/dumber/internal/ui/layout" | |
| @@ -21,10 +21,10 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | |
| 23 | 23 | 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 | |
| 28 | 28 | ||
| 29 | 29 | webViews map[entity.PaneID]*webkit.WebView | |
| 30 | 30 | paneTitles map[entity.PaneID]string | |
| @@ -68,7 +68,7 @@ func NewContentCoordinator( | |||
| 68 | 68 | ctx context.Context, | |
| 69 | 69 | pool *webkit.WebViewPool, | |
| 70 | 70 | widgetFactory layout.WidgetFactory, | |
| 71 | - | faviconCache *cache.FaviconCache, | |
| 71 | + | faviconAdapter *adapter.FaviconAdapter, | |
| 72 | 72 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView), | |
| 73 | 73 | zoomUC *usecase.ManageZoomUseCase, | |
| 74 | 74 | ) *ContentCoordinator { | |
| @@ -76,15 +76,15 @@ func NewContentCoordinator( | |||
| 76 | 76 | log.Debug().Msg("creating content coordinator") | |
| 77 | 77 | ||
| 78 | 78 | 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), | |
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | 90 | ||
| @@ -386,18 +386,12 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity | |||
| 386 | 386 | } | |
| 387 | 387 | uri := wv.URI() | |
| 388 | 388 | ||
| 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 != "" { | |
| 395 | 391 | c.navOriginMu.RLock() | |
| 396 | 392 | originURL := c.navOrigins[paneID] | |
| 397 | 393 | c.navOriginMu.RUnlock() | |
| 398 | - | if originURL != "" && originURL != uri { | |
| 399 | - | c.faviconCache.SetByURL(originURL, favicon) | |
| 400 | - | } | |
| 394 | + | c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon) | |
| 401 | 395 | } | |
| 402 | 396 | ||
| 403 | 397 | // 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). | |
| 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 | |
| 451 | 445 | } | |
| 452 | 446 | ||
| 453 | 447 | // SetNavigationOrigin records the original URL before navigation starts. | |
| @@ -463,12 +457,12 @@ func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url strin | |||
| 463 | 457 | // title bar immediately if a cached favicon exists for the URL. | |
| 464 | 458 | // This provides instant favicon display without waiting for WebKit. | |
| 465 | 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 | 461 | return | |
| 468 | 462 | } | |
| 469 | 463 | ||
| 470 | 464 | // Check memory and disk cache (no external fetch) | |
| 471 | - | texture := c.faviconCache.GetFromCacheByURL(url) | |
| 465 | + | texture := c.faviconAdapter.PreloadFromCache(url) | |
| 472 | 466 | if texture == nil { | |
| 473 | 467 | return | |
| 474 | 468 | } | |
| @@ -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.WebViewPool | 24 | + | pool *webkit.WebViewPool |
| 25 | - | widgetFactory layout.WidgetFactory | 25 | + | widgetFactory layout.WidgetFactory |
| 26 | - | faviconCache *cache.FaviconCache | 26 | + | faviconAdapter *adapter.FaviconAdapter |
| 27 | - | zoomUC *usecase.ManageZoomUseCase | 27 | + | zoomUC *usecase.ManageZoomUseCase |
| 28 | 28 | ||||
| 29 | webViews map[entity.PaneID]*webkit.WebView | 29 | webViews map[entity.PaneID]*webkit.WebView | ||
| 30 | paneTitles map[entity.PaneID]string | 30 | 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 stack | 397 | // 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.faviconCache | 444 | + | 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 | return | 461 | 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 | return | 467 | return | ||
| 474 | } | 468 | } | ||
M internal/ui/deps.go +3 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 12 | 13 | "github.com/bnema/dumber/internal/ui/theme" | |
| 13 | 14 | ) | |
| @@ -47,7 +48,8 @@ type Dependencies struct { | |||
| 47 | 48 | CopyURLUC *usecase.CopyURLUseCase | |
| 48 | 49 | ||
| 49 | 50 | // Infrastructure Adapters | |
| 50 | - | Clipboard port.Clipboard | |
| 51 | + | Clipboard port.Clipboard | |
| 52 | + | FaviconService *favicon.Service | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | // 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.CopyURLUseCase | 48 | CopyURLUC *usecase.CopyURLUseCase | ||
| 48 | 49 | ||||
| 49 | // Infrastructure Adapters | 50 | // Infrastructure Adapters | ||
| 50 | - | Clipboard port.Clipboard | 51 | + | 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. | ||