feat(favicon): add disk cache persistence for DuckDuckGo favicons
- commit
- 2067abe4e9a4099615044cc2d56ab22e3a728bec
- author
- bnema · · 2025-12-16 21:57 UTC
- parent
- a6433c1
- signature
- unsigned
3 files · +189 -24
M internal/infrastructure/config/xdg.go +20 -0
| @@ -22,6 +22,7 @@ type XDGDirs struct { | |||
| 22 | 22 | ConfigHome string | |
| 23 | 23 | DataHome string | |
| 24 | 24 | StateHome string | |
| 25 | + | CacheHome string | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | // GetXDGDirs returns the XDG Base Directory paths for dumber. | |
| @@ -41,6 +42,7 @@ func GetXDGDirs() (*XDGDirs, error) { | |||
| 41 | 42 | ConfigHome: devDir, | |
| 42 | 43 | DataHome: devDir, | |
| 43 | 44 | StateHome: devDir, | |
| 45 | + | CacheHome: devDir, | |
| 44 | 46 | }, nil | |
| 45 | 47 | } | |
| 46 | 48 | ||
| @@ -70,10 +72,18 @@ func GetXDGDirs() (*XDGDirs, error) { | |||
| 70 | 72 | } | |
| 71 | 73 | stateHome = filepath.Join(stateHome, appName) | |
| 72 | 74 | ||
| 75 | + | // XDG_CACHE_HOME | |
| 76 | + | cacheHome := os.Getenv("XDG_CACHE_HOME") | |
| 77 | + | if cacheHome == "" { | |
| 78 | + | cacheHome = filepath.Join(homeDir, ".cache") | |
| 79 | + | } | |
| 80 | + | cacheHome = filepath.Join(cacheHome, appName) | |
| 81 | + | ||
| 73 | 82 | return &XDGDirs{ | |
| 74 | 83 | ConfigHome: configHome, | |
| 75 | 84 | DataHome: dataHome, | |
| 76 | 85 | StateHome: stateHome, | |
| 86 | + | CacheHome: cacheHome, | |
| 77 | 87 | }, nil | |
| 78 | 88 | } | |
| 79 | 89 | ||
| @@ -153,6 +163,16 @@ func GetFilterCacheFile() (string, error) { | |||
| 153 | 163 | return filepath.Join(cacheDir, "filters.cache"), nil | |
| 154 | 164 | } | |
| 155 | 165 | ||
| 166 | + | // GetFaviconCacheDir returns the XDG-compliant favicon cache directory for dumber. | |
| 167 | + | // Favicon cache is stored in XDG_CACHE_HOME as it's transient data that can be refetched. | |
| 168 | + | func GetFaviconCacheDir() (string, error) { | |
| 169 | + | dirs, err := GetXDGDirs() | |
| 170 | + | if err != nil { | |
| 171 | + | return "", err | |
| 172 | + | } | |
| 173 | + | return filepath.Join(dirs.CacheHome, "favicons"), nil | |
| 174 | + | } | |
| 175 | + | ||
| 156 | 176 | // EnsureDirectories creates the XDG directories if they don't exist. | |
| 157 | 177 | func EnsureDirectories() error { | |
| 158 | 178 | dirs, err := GetXDGDirs() | |
| @@ -22,6 +22,7 @@ type XDGDirs struct { | |||||
| 22 | ConfigHome string | 22 | ConfigHome string | ||
| 23 | DataHome string | 23 | DataHome string | ||
| 24 | StateHome string | 24 | StateHome string | ||
| 25 | + | CacheHome string | |||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | // GetXDGDirs returns the XDG Base Directory paths for dumber. | 28 | // GetXDGDirs returns the XDG Base Directory paths for dumber. | ||
| @@ -41,6 +42,7 @@ func GetXDGDirs() (*XDGDirs, error) { | |||||
| 41 | ConfigHome: devDir, | 42 | ConfigHome: devDir, | ||
| 42 | DataHome: devDir, | 43 | DataHome: devDir, | ||
| 43 | StateHome: devDir, | 44 | StateHome: devDir, | ||
| 45 | + | CacheHome: devDir, | |||
| 44 | }, nil | 46 | }, nil | ||
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| @@ -70,10 +72,18 @@ func GetXDGDirs() (*XDGDirs, error) { | |||||
| 70 | } | 72 | } | ||
| 71 | stateHome = filepath.Join(stateHome, appName) | 73 | stateHome = filepath.Join(stateHome, appName) | ||
| 72 | 74 | ||||
| 75 | + | // XDG_CACHE_HOME | |||
| 76 | + | cacheHome := os.Getenv("XDG_CACHE_HOME") | |||
| 77 | + | if cacheHome == "" { | |||
| 78 | + | cacheHome = filepath.Join(homeDir, ".cache") | |||
| 79 | + | } | |||
| 80 | + | cacheHome = filepath.Join(cacheHome, appName) | |||
| 81 | + | ||||
| 73 | return &XDGDirs{ | 82 | return &XDGDirs{ | ||
| 74 | ConfigHome: configHome, | 83 | ConfigHome: configHome, | ||
| 75 | DataHome: dataHome, | 84 | DataHome: dataHome, | ||
| 76 | StateHome: stateHome, | 85 | StateHome: stateHome, | ||
| 86 | + | CacheHome: cacheHome, | |||
| 77 | }, nil | 87 | }, nil | ||
| 78 | } | 88 | } | ||
| 79 | 89 | ||||
| @@ -153,6 +163,16 @@ func GetFilterCacheFile() (string, error) { | |||||
| 153 | return filepath.Join(cacheDir, "filters.cache"), nil | 163 | return filepath.Join(cacheDir, "filters.cache"), nil | ||
| 154 | } | 164 | } | ||
| 155 | 165 | ||||
| 166 | + | // GetFaviconCacheDir returns the XDG-compliant favicon cache directory for dumber. | |||
| 167 | + | // Favicon cache is stored in XDG_CACHE_HOME as it's transient data that can be refetched. | |||
| 168 | + | func GetFaviconCacheDir() (string, error) { | |||
| 169 | + | dirs, err := GetXDGDirs() | |||
| 170 | + | if err != nil { | |||
| 171 | + | return "", err | |||
| 172 | + | } | |||
| 173 | + | return filepath.Join(dirs.CacheHome, "favicons"), nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 156 | // EnsureDirectories creates the XDG directories if they don't exist. | 176 | // EnsureDirectories creates the XDG directories if they don't exist. | ||
| 157 | func EnsureDirectories() error { | 177 | func EnsureDirectories() error { | ||
| 158 | dirs, err := GetXDGDirs() | 178 | dirs, err := GetXDGDirs() | ||
M internal/ui/app.go +3 -0
| @@ -230,6 +230,9 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 230 | 230 | a.cancel(errors.New("application shutdown")) | |
| 231 | 231 | ||
| 232 | 232 | // Cleanup resources | |
| 233 | + | if a.faviconCache != nil { | |
| 234 | + | a.faviconCache.Close() | |
| 235 | + | } | |
| 233 | 236 | if a.deps.Pool != nil { | |
| 234 | 237 | a.deps.Pool.Close() | |
| 235 | 238 | } | |
| @@ -230,6 +230,9 @@ func (a *App) onShutdown(ctx context.Context) { | |||||
| 230 | a.cancel(errors.New("application shutdown")) | 230 | a.cancel(errors.New("application shutdown")) | ||
| 231 | 231 | ||||
| 232 | // Cleanup resources | 232 | // Cleanup resources | ||
| 233 | + | if a.faviconCache != nil { | |||
| 234 | + | a.faviconCache.Close() | |||
| 235 | + | } | |||
| 233 | if a.deps.Pool != nil { | 236 | if a.deps.Pool != nil { | ||
| 234 | a.deps.Pool.Close() | 237 | a.deps.Pool.Close() | ||
| 235 | } | 238 | } | ||
M internal/ui/cache/favicon_cache.go +166 -24
| @@ -7,10 +7,13 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "net/http" | |
| 9 | 9 | "net/url" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 10 | 12 | "strings" | |
| 11 | 13 | "sync" | |
| 12 | 14 | "time" | |
| 13 | 15 | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 18 | "github.com/bnema/puregotk-webkit/webkit" | |
| 16 | 19 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| @@ -23,29 +26,56 @@ const ( | |||
| 23 | 26 | duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico" | |
| 24 | 27 | // HTTP client timeout for favicon fetch | |
| 25 | 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 | |
| 26 | 34 | ) | |
| 27 | 35 | ||
| 28 | - | // FaviconCache provides a two-tier favicon caching system: | |
| 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: | |
| 29 | 43 | // 1. In-memory cache keyed by domain for fast lookups | |
| 30 | - | // 2. WebKit FaviconDatabase as persistent backing store | |
| 31 | - | // 3. Google favicon API as fallback | |
| 44 | + | // 2. Disk cache for persistence across restarts | |
| 45 | + | // 3. WebKit FaviconDatabase as backing store | |
| 46 | + | // 4. DuckDuckGo favicon API as fallback | |
| 32 | 47 | type FaviconCache struct { | |
| 33 | - | mu sync.RWMutex | |
| 34 | - | cache map[string]*gdk.Texture // key: domain (e.g., "github.com") | |
| 35 | - | faviconDB *webkit.FaviconDatabase | |
| 36 | - | client *http.Client | |
| 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 | |
| 37 | 55 | } | |
| 38 | 56 | ||
| 39 | 57 | // NewFaviconCache creates a new FaviconCache with the given FaviconDatabase. | |
| 40 | 58 | // The faviconDB can be nil, in which case only the in-memory cache is used. | |
| 41 | 59 | func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache { | |
| 42 | - | return &FaviconCache{ | |
| 43 | - | cache: make(map[string]*gdk.Texture), | |
| 44 | - | faviconDB: faviconDB, | |
| 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), | |
| 45 | 68 | client: &http.Client{ | |
| 46 | 69 | Timeout: faviconFetchTimeout, | |
| 47 | 70 | }, | |
| 48 | 71 | } | |
| 72 | + | ||
| 73 | + | // Start background writer goroutine | |
| 74 | + | if diskCacheDir != "" { | |
| 75 | + | go fc.diskWriter() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return fc | |
| 49 | 79 | } | |
| 50 | 80 | ||
| 51 | 81 | // extractDomain extracts the normalized domain (host) from a URL string. | |
| @@ -118,6 +148,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||
| 118 | 148 | return | |
| 119 | 149 | } | |
| 120 | 150 | ||
| 151 | + | // Check disk cache second | |
| 152 | + | if texture := fc.loadFromDisk(domain); texture != nil { | |
| 153 | + | fc.Set(domain, texture) | |
| 154 | + | callback(texture) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | ||
| 121 | 158 | // Helper to invoke callback on main thread | |
| 122 | 159 | invokeCallback := func(texture *gdk.Texture) { | |
| 123 | 160 | cb := glib.SourceFunc(func(uintptr) bool { | |
| @@ -130,9 +167,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||
| 130 | 167 | // Helper to fetch from external API and invoke callback | |
| 131 | 168 | fetchExternal := func() { | |
| 132 | 169 | go func() { | |
| 133 | - | texture := fc.fetchFromExternal(ctx, domain) | |
| 170 | + | texture, rawData := fc.fetchFromExternal(ctx, domain) | |
| 134 | 171 | if texture != nil { | |
| 135 | 172 | fc.Set(domain, texture) | |
| 173 | + | // Queue async disk write | |
| 174 | + | if len(rawData) > 0 { | |
| 175 | + | fc.queueDiskWrite(domain, rawData) | |
| 176 | + | } | |
| 136 | 177 | } | |
| 137 | 178 | invokeCallback(texture) | |
| 138 | 179 | }() | |
| @@ -170,61 +211,61 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||
| 170 | 211 | } | |
| 171 | 212 | ||
| 172 | 213 | // fetchFromExternal fetches a favicon from DuckDuckGo's favicon API. | |
| 173 | - | // Returns nil if fetch fails. | |
| 174 | - | func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) *gdk.Texture { | |
| 214 | + | // Returns the texture and raw bytes (bytes used for disk cache). | |
| 215 | + | func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) (texture *gdk.Texture, rawData []byte) { | |
| 175 | 216 | log := logging.FromContext(ctx) | |
| 176 | 217 | ||
| 177 | 218 | if domain == "" { | |
| 178 | - | return nil | |
| 219 | + | return nil, nil | |
| 179 | 220 | } | |
| 180 | 221 | ||
| 181 | 222 | faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain)) | |
| 182 | 223 | log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo") | |
| 183 | 224 | ||
| 184 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, nil) | |
| 225 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody) | |
| 185 | 226 | if err != nil { | |
| 186 | 227 | log.Debug().Err(err).Msg("failed to create favicon request") | |
| 187 | - | return nil | |
| 228 | + | return nil, nil | |
| 188 | 229 | } | |
| 189 | 230 | ||
| 190 | 231 | resp, err := fc.client.Do(req) | |
| 191 | 232 | if err != nil { | |
| 192 | 233 | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo") | |
| 193 | - | return nil | |
| 234 | + | return nil, nil | |
| 194 | 235 | } | |
| 195 | 236 | defer resp.Body.Close() | |
| 196 | 237 | ||
| 197 | 238 | if resp.StatusCode != http.StatusOK { | |
| 198 | 239 | log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status") | |
| 199 | - | return nil | |
| 240 | + | return nil, nil | |
| 200 | 241 | } | |
| 201 | 242 | ||
| 202 | 243 | data, err := io.ReadAll(resp.Body) | |
| 203 | 244 | if err != nil { | |
| 204 | 245 | log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response") | |
| 205 | - | return nil | |
| 246 | + | return nil, nil | |
| 206 | 247 | } | |
| 207 | 248 | ||
| 208 | 249 | if len(data) == 0 { | |
| 209 | 250 | log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo") | |
| 210 | - | return nil | |
| 251 | + | return nil, nil | |
| 211 | 252 | } | |
| 212 | 253 | ||
| 213 | 254 | // Convert bytes to GdkTexture | |
| 214 | 255 | bytes := glib.NewBytes(data, uint(len(data))) | |
| 215 | 256 | if bytes == nil { | |
| 216 | 257 | log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data") | |
| 217 | - | return nil | |
| 258 | + | return nil, nil | |
| 218 | 259 | } | |
| 219 | 260 | ||
| 220 | - | texture, err := gdk.NewTextureFromBytes(bytes) | |
| 261 | + | texture, err = gdk.NewTextureFromBytes(bytes) | |
| 221 | 262 | if err != nil { | |
| 222 | 263 | log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes") | |
| 223 | - | return nil | |
| 264 | + | return nil, nil | |
| 224 | 265 | } | |
| 225 | 266 | ||
| 226 | 267 | log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached") | |
| 227 | - | return texture | |
| 268 | + | return texture, data | |
| 228 | 269 | } | |
| 229 | 270 | ||
| 230 | 271 | // Clear removes all entries from the in-memory cache. | |
| @@ -241,3 +282,104 @@ func (fc *FaviconCache) Size() int { | |||
| 241 | 282 | fc.mu.RUnlock() | |
| 242 | 283 | return size | |
| 243 | 284 | } | |
| 285 | + | ||
| 286 | + | // Close stops the background writer goroutine. | |
| 287 | + | func (fc *FaviconCache) Close() { | |
| 288 | + | fc.closeOnce.Do(func() { | |
| 289 | + | if fc.writeChan != nil { | |
| 290 | + | close(fc.writeChan) | |
| 291 | + | } | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // sanitizeDomainForFilename converts a domain to a safe filename. | |
| 296 | + | func sanitizeDomainForFilename(domain string) string { | |
| 297 | + | replacer := strings.NewReplacer( | |
| 298 | + | ":", "_", | |
| 299 | + | "/", "_", | |
| 300 | + | "\\", "_", | |
| 301 | + | "*", "_", | |
| 302 | + | "?", "_", | |
| 303 | + | "\"", "_", | |
| 304 | + | "<", "_", | |
| 305 | + | ">", "_", | |
| 306 | + | "|", "_", | |
| 307 | + | ) | |
| 308 | + | return replacer.Replace(domain) + ".ico" | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | // loadFromDisk attempts to load a favicon from disk cache. | |
| 312 | + | // Returns nil if not found or on error. | |
| 313 | + | func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | |
| 314 | + | if fc.diskCacheDir == "" { | |
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | filename := sanitizeDomainForFilename(domain) | |
| 319 | + | path := filepath.Join(fc.diskCacheDir, filename) | |
| 320 | + | ||
| 321 | + | //nolint:gosec // path is constructed from sanitized domain, not user input | |
| 322 | + | data, err := os.ReadFile(path) | |
| 323 | + | if err != nil { | |
| 324 | + | return nil | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | if len(data) == 0 { | |
| 328 | + | return nil | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | bytes := glib.NewBytes(data, uint(len(data))) | |
| 332 | + | if bytes == nil { | |
| 333 | + | return nil | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | texture, err := gdk.NewTextureFromBytes(bytes) | |
| 337 | + | if err != nil { | |
| 338 | + | return nil | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | return texture | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // writeToDisk atomically writes favicon data to disk. | |
| 345 | + | func (fc *FaviconCache) writeToDisk(domain string, data []byte) { | |
| 346 | + | if fc.diskCacheDir == "" || len(data) == 0 { | |
| 347 | + | return | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // Ensure directory exists (lazy initialization) | |
| 351 | + | if err := os.MkdirAll(fc.diskCacheDir, diskCacheDirPerm); err != nil { | |
| 352 | + | return | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | filename := sanitizeDomainForFilename(domain) | |
| 356 | + | finalPath := filepath.Join(fc.diskCacheDir, filename) | |
| 357 | + | tempPath := finalPath + ".tmp" | |
| 358 | + | ||
| 359 | + | // Write to temp file | |
| 360 | + | if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil { | |
| 361 | + | return | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | // Atomic rename | |
| 365 | + | if err := os.Rename(tempPath, finalPath); err != nil { | |
| 366 | + | _ = os.Remove(tempPath) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // queueDiskWrite sends favicon data to be written asynchronously. | |
| 371 | + | func (fc *FaviconCache) queueDiskWrite(domain string, data []byte) { | |
| 372 | + | select { | |
| 373 | + | case fc.writeChan <- diskWrite{domain: domain, data: data}: | |
| 374 | + | // queued successfully | |
| 375 | + | default: | |
| 376 | + | // channel full, skip write | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // diskWriter processes async write requests. | |
| 381 | + | func (fc *FaviconCache) diskWriter() { | |
| 382 | + | for write := range fc.writeChan { | |
| 383 | + | fc.writeToDisk(write.domain, write.data) | |
| 384 | + | } | |
| 385 | + | } | |
| @@ -7,10 +7,13 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | "net/url" | 9 | "net/url" | ||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 10 | "strings" | 12 | "strings" | ||
| 11 | "sync" | 13 | "sync" | ||
| 12 | "time" | 14 | "time" | ||
| 13 | 15 | ||||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 14 | "github.com/bnema/dumber/internal/logging" | 17 | "github.com/bnema/dumber/internal/logging" | ||
| 15 | "github.com/bnema/puregotk-webkit/webkit" | 18 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 16 | "github.com/jwijenbergh/puregotk/v4/gdk" | 19 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| @@ -23,29 +26,56 @@ const ( | |||||
| 23 | duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico" | 26 | duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico" | ||
| 24 | // HTTP client timeout for favicon fetch | 27 | // HTTP client timeout for favicon fetch | ||
| 25 | faviconFetchTimeout = 5 * time.Second | 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 | |||
| 26 | ) | 34 | ) | ||
| 27 | 35 | ||||
| 28 | - | // FaviconCache provides a two-tier favicon caching system: | 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: | |||
| 29 | // 1. In-memory cache keyed by domain for fast lookups | 43 | // 1. In-memory cache keyed by domain for fast lookups | ||
| 30 | - | // 2. WebKit FaviconDatabase as persistent backing store | 44 | + | // 2. Disk cache for persistence across restarts |
| 31 | - | // 3. Google favicon API as fallback | 45 | + | // 3. WebKit FaviconDatabase as backing store |
| 46 | + | // 4. DuckDuckGo favicon API as fallback | |||
| 32 | type FaviconCache struct { | 47 | type FaviconCache struct { | ||
| 33 | - | mu sync.RWMutex | 48 | + | mu sync.RWMutex |
| 34 | - | cache map[string]*gdk.Texture // key: domain (e.g., "github.com") | 49 | + | cache map[string]*gdk.Texture // key: domain (e.g., "github.com") |
| 35 | - | faviconDB *webkit.FaviconDatabase | 50 | + | faviconDB *webkit.FaviconDatabase |
| 36 | - | client *http.Client | 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 | |||
| 37 | } | 55 | } | ||
| 38 | 56 | ||||
| 39 | // NewFaviconCache creates a new FaviconCache with the given FaviconDatabase. | 57 | // NewFaviconCache creates a new FaviconCache with the given FaviconDatabase. | ||
| 40 | // The faviconDB can be nil, in which case only the in-memory cache is used. | 58 | // The faviconDB can be nil, in which case only the in-memory cache is used. | ||
| 41 | func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache { | 59 | func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache { | ||
| 42 | - | return &FaviconCache{ | 60 | + | // Get disk cache directory (fail silently if error) |
| 43 | - | cache: make(map[string]*gdk.Texture), | 61 | + | diskCacheDir, _ := config.GetFaviconCacheDir() |
| 44 | - | faviconDB: faviconDB, | 62 | + | |
| 63 | + | fc := &FaviconCache{ | |||
| 64 | + | cache: make(map[string]*gdk.Texture), | |||
| 65 | + | faviconDB: faviconDB, | |||
| 66 | + | diskCacheDir: diskCacheDir, | |||
| 67 | + | writeChan: make(chan diskWrite, diskWriteBufferSize), | |||
| 45 | client: &http.Client{ | 68 | client: &http.Client{ | ||
| 46 | Timeout: faviconFetchTimeout, | 69 | Timeout: faviconFetchTimeout, | ||
| 47 | }, | 70 | }, | ||
| 48 | } | 71 | } | ||
| 72 | + | ||||
| 73 | + | // Start background writer goroutine | |||
| 74 | + | if diskCacheDir != "" { | |||
| 75 | + | go fc.diskWriter() | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return fc | |||
| 49 | } | 79 | } | ||
| 50 | 80 | ||||
| 51 | // extractDomain extracts the normalized domain (host) from a URL string. | 81 | // extractDomain extracts the normalized domain (host) from a URL string. | ||
| @@ -118,6 +148,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||||
| 118 | return | 148 | return | ||
| 119 | } | 149 | } | ||
| 120 | 150 | ||||
| 151 | + | // Check disk cache second | |||
| 152 | + | if texture := fc.loadFromDisk(domain); texture != nil { | |||
| 153 | + | fc.Set(domain, texture) | |||
| 154 | + | callback(texture) | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | ||||
| 121 | // Helper to invoke callback on main thread | 158 | // Helper to invoke callback on main thread | ||
| 122 | invokeCallback := func(texture *gdk.Texture) { | 159 | invokeCallback := func(texture *gdk.Texture) { | ||
| 123 | cb := glib.SourceFunc(func(uintptr) bool { | 160 | cb := glib.SourceFunc(func(uintptr) bool { | ||
| @@ -130,9 +167,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||||
| 130 | // Helper to fetch from external API and invoke callback | 167 | // Helper to fetch from external API and invoke callback | ||
| 131 | fetchExternal := func() { | 168 | fetchExternal := func() { | ||
| 132 | go func() { | 169 | go func() { | ||
| 133 | - | texture := fc.fetchFromExternal(ctx, domain) | 170 | + | texture, rawData := fc.fetchFromExternal(ctx, domain) |
| 134 | if texture != nil { | 171 | if texture != nil { | ||
| 135 | fc.Set(domain, texture) | 172 | fc.Set(domain, texture) | ||
| 173 | + | // Queue async disk write | |||
| 174 | + | if len(rawData) > 0 { | |||
| 175 | + | fc.queueDiskWrite(domain, rawData) | |||
| 176 | + | } | |||
| 136 | } | 177 | } | ||
| 137 | invokeCallback(texture) | 178 | invokeCallback(texture) | ||
| 138 | }() | 179 | }() | ||
| @@ -170,61 +211,61 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback | |||||
| 170 | } | 211 | } | ||
| 171 | 212 | ||||
| 172 | // fetchFromExternal fetches a favicon from DuckDuckGo's favicon API. | 213 | // fetchFromExternal fetches a favicon from DuckDuckGo's favicon API. | ||
| 173 | - | // Returns nil if fetch fails. | 214 | + | // Returns the texture and raw bytes (bytes used for disk cache). |
| 174 | - | func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) *gdk.Texture { | 215 | + | func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) (texture *gdk.Texture, rawData []byte) { |
| 175 | log := logging.FromContext(ctx) | 216 | log := logging.FromContext(ctx) | ||
| 176 | 217 | ||||
| 177 | if domain == "" { | 218 | if domain == "" { | ||
| 178 | - | return nil | 219 | + | return nil, nil |
| 179 | } | 220 | } | ||
| 180 | 221 | ||||
| 181 | faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain)) | 222 | faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain)) | ||
| 182 | log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo") | 223 | log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo") | ||
| 183 | 224 | ||||
| 184 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, nil) | 225 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody) |
| 185 | if err != nil { | 226 | if err != nil { | ||
| 186 | log.Debug().Err(err).Msg("failed to create favicon request") | 227 | log.Debug().Err(err).Msg("failed to create favicon request") | ||
| 187 | - | return nil | 228 | + | return nil, nil |
| 188 | } | 229 | } | ||
| 189 | 230 | ||||
| 190 | resp, err := fc.client.Do(req) | 231 | resp, err := fc.client.Do(req) | ||
| 191 | if err != nil { | 232 | if err != nil { | ||
| 192 | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo") | 233 | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo") | ||
| 193 | - | return nil | 234 | + | return nil, nil |
| 194 | } | 235 | } | ||
| 195 | defer resp.Body.Close() | 236 | defer resp.Body.Close() | ||
| 196 | 237 | ||||
| 197 | if resp.StatusCode != http.StatusOK { | 238 | if resp.StatusCode != http.StatusOK { | ||
| 198 | log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status") | 239 | log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status") | ||
| 199 | - | return nil | 240 | + | return nil, nil |
| 200 | } | 241 | } | ||
| 201 | 242 | ||||
| 202 | data, err := io.ReadAll(resp.Body) | 243 | data, err := io.ReadAll(resp.Body) | ||
| 203 | if err != nil { | 244 | if err != nil { | ||
| 204 | log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response") | 245 | log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response") | ||
| 205 | - | return nil | 246 | + | return nil, nil |
| 206 | } | 247 | } | ||
| 207 | 248 | ||||
| 208 | if len(data) == 0 { | 249 | if len(data) == 0 { | ||
| 209 | log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo") | 250 | log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo") | ||
| 210 | - | return nil | 251 | + | return nil, nil |
| 211 | } | 252 | } | ||
| 212 | 253 | ||||
| 213 | // Convert bytes to GdkTexture | 254 | // Convert bytes to GdkTexture | ||
| 214 | bytes := glib.NewBytes(data, uint(len(data))) | 255 | bytes := glib.NewBytes(data, uint(len(data))) | ||
| 215 | if bytes == nil { | 256 | if bytes == nil { | ||
| 216 | log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data") | 257 | log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data") | ||
| 217 | - | return nil | 258 | + | return nil, nil |
| 218 | } | 259 | } | ||
| 219 | 260 | ||||
| 220 | - | texture, err := gdk.NewTextureFromBytes(bytes) | 261 | + | texture, err = gdk.NewTextureFromBytes(bytes) |
| 221 | if err != nil { | 262 | if err != nil { | ||
| 222 | log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes") | 263 | log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes") | ||
| 223 | - | return nil | 264 | + | return nil, nil |
| 224 | } | 265 | } | ||
| 225 | 266 | ||||
| 226 | log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached") | 267 | log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached") | ||
| 227 | - | return texture | 268 | + | return texture, data |
| 228 | } | 269 | } | ||
| 229 | 270 | ||||
| 230 | // Clear removes all entries from the in-memory cache. | 271 | // Clear removes all entries from the in-memory cache. | ||
| @@ -241,3 +282,104 @@ func (fc *FaviconCache) Size() int { | |||||
| 241 | fc.mu.RUnlock() | 282 | fc.mu.RUnlock() | ||
| 242 | return size | 283 | return size | ||
| 243 | } | 284 | } | ||
| 285 | + | ||||
| 286 | + | // Close stops the background writer goroutine. | |||
| 287 | + | func (fc *FaviconCache) Close() { | |||
| 288 | + | fc.closeOnce.Do(func() { | |||
| 289 | + | if fc.writeChan != nil { | |||
| 290 | + | close(fc.writeChan) | |||
| 291 | + | } | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // sanitizeDomainForFilename converts a domain to a safe filename. | |||
| 296 | + | func sanitizeDomainForFilename(domain string) string { | |||
| 297 | + | replacer := strings.NewReplacer( | |||
| 298 | + | ":", "_", | |||
| 299 | + | "/", "_", | |||
| 300 | + | "\\", "_", | |||
| 301 | + | "*", "_", | |||
| 302 | + | "?", "_", | |||
| 303 | + | "\"", "_", | |||
| 304 | + | "<", "_", | |||
| 305 | + | ">", "_", | |||
| 306 | + | "|", "_", | |||
| 307 | + | ) | |||
| 308 | + | return replacer.Replace(domain) + ".ico" | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | // loadFromDisk attempts to load a favicon from disk cache. | |||
| 312 | + | // Returns nil if not found or on error. | |||
| 313 | + | func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture { | |||
| 314 | + | if fc.diskCacheDir == "" { | |||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | filename := sanitizeDomainForFilename(domain) | |||
| 319 | + | path := filepath.Join(fc.diskCacheDir, filename) | |||
| 320 | + | ||||
| 321 | + | //nolint:gosec // path is constructed from sanitized domain, not user input | |||
| 322 | + | data, err := os.ReadFile(path) | |||
| 323 | + | if err != nil { | |||
| 324 | + | return nil | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | if len(data) == 0 { | |||
| 328 | + | return nil | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | bytes := glib.NewBytes(data, uint(len(data))) | |||
| 332 | + | if bytes == nil { | |||
| 333 | + | return nil | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | texture, err := gdk.NewTextureFromBytes(bytes) | |||
| 337 | + | if err != nil { | |||
| 338 | + | return nil | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | return texture | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // writeToDisk atomically writes favicon data to disk. | |||
| 345 | + | func (fc *FaviconCache) writeToDisk(domain string, data []byte) { | |||
| 346 | + | if fc.diskCacheDir == "" || len(data) == 0 { | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // Ensure directory exists (lazy initialization) | |||
| 351 | + | if err := os.MkdirAll(fc.diskCacheDir, diskCacheDirPerm); err != nil { | |||
| 352 | + | return | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | filename := sanitizeDomainForFilename(domain) | |||
| 356 | + | finalPath := filepath.Join(fc.diskCacheDir, filename) | |||
| 357 | + | tempPath := finalPath + ".tmp" | |||
| 358 | + | ||||
| 359 | + | // Write to temp file | |||
| 360 | + | if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil { | |||
| 361 | + | return | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | // Atomic rename | |||
| 365 | + | if err := os.Rename(tempPath, finalPath); err != nil { | |||
| 366 | + | _ = os.Remove(tempPath) | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // queueDiskWrite sends favicon data to be written asynchronously. | |||
| 371 | + | func (fc *FaviconCache) queueDiskWrite(domain string, data []byte) { | |||
| 372 | + | select { | |||
| 373 | + | case fc.writeChan <- diskWrite{domain: domain, data: data}: | |||
| 374 | + | // queued successfully | |||
| 375 | + | default: | |||
| 376 | + | // channel full, skip write | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | // diskWriter processes async write requests. | |||
| 381 | + | func (fc *FaviconCache) diskWriter() { | |||
| 382 | + | for write := range fc.writeChan { | |||
| 383 | + | fc.writeToDisk(write.domain, write.data) | |||
| 384 | + | } | |||
| 385 | + | } | |||