1package cache
2
3import (
4 "crypto/md5"
5 "fmt"
6 "image"
7 "image/color"
8 _ "image/gif" // Register GIF decoder
9 _ "image/jpeg" // Register JPEG decoder
10 "image/png"
11 "io"
12 "log"
13 "net/http"
14 "os"
15 "path/filepath"
16 "time"
17
18 "github.com/bnema/dumber/internal/config"
19 "github.com/sergeymakinen/go-ico"
20)
21
22// FaviconCache manages favicon caching to local files
23type FaviconCache struct {
24 cacheDir string
25}
26
27// NewFaviconCache creates a new favicon cache
28func NewFaviconCache() (*FaviconCache, error) {
29 dataDir, err := config.GetDataDir()
30 if err != nil {
31 return nil, fmt.Errorf("failed to get data directory: %w", err)
32 }
33
34 cacheDir := filepath.Join(dataDir, "favicons")
35 if err := os.MkdirAll(cacheDir, 0755); err != nil {
36 return nil, fmt.Errorf("failed to create favicon cache directory: %w", err)
37 }
38
39 return &FaviconCache{
40 cacheDir: cacheDir,
41 }, nil
42}
43
44// GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise
45func (fc *FaviconCache) GetCachedPath(faviconURL string) string {
46 if faviconURL == "" {
47 return ""
48 }
49
50 filename := fc.getFilename(faviconURL)
51 cachedPath := filepath.Join(fc.cacheDir, filename)
52
53 // Check if file exists and is not too old (7 days)
54 if info, err := os.Stat(cachedPath); err == nil {
55 if time.Since(info.ModTime()) < 7*24*time.Hour {
56 return cachedPath
57 }
58 }
59
60 return ""
61}
62
63// CacheAsync downloads and caches a favicon asynchronously
64func (fc *FaviconCache) CacheAsync(faviconURL string) {
65 if faviconURL == "" {
66 return
67 }
68
69 go func() {
70 if err := fc.downloadFavicon(faviconURL); err != nil {
71 // Silently ignore errors in background download
72 }
73 }()
74}
75
76// getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility
77func (fc *FaviconCache) getFilename(faviconURL string) string {
78 hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL)))
79 return hash + ".png"
80}
81
82// downloadFavicon downloads a favicon and saves it to cache
83func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
84 filename := fc.getFilename(faviconURL)
85 cachedPath := filepath.Join(fc.cacheDir, filename)
86
87 // Don't download if already exists and is recent
88 if info, err := os.Stat(cachedPath); err == nil {
89 if time.Since(info.ModTime()) < 7*24*time.Hour {
90 return nil
91 }
92 }
93
94 // Create HTTP client with timeout and redirect handling
95 client := &http.Client{
96 Timeout: 10 * time.Second,
97 CheckRedirect: func(req *http.Request, via []*http.Request) error {
98 if len(via) >= 5 {
99 return fmt.Errorf("too many redirects")
100 }
101 return nil
102 },
103 }
104
105 resp, err := client.Get(faviconURL)
106 if err != nil {
107 return fmt.Errorf("failed to download favicon: %w", err)
108 }
109 defer resp.Body.Close()
110
111 // Log redirects for debugging
112 if resp.Request.URL.String() != faviconURL {
113 log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String())
114 }
115
116 if resp.StatusCode != http.StatusOK {
117 return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
118 }
119
120 // Limit response body size
121 limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
122
123 // Download to temporary file first
124 tempDownloadFile := cachedPath + ".download"
125 downloadFile, err := os.Create(tempDownloadFile)
126 if err != nil {
127 return fmt.Errorf("failed to create download temp file: %w", err)
128 }
129 defer downloadFile.Close()
130 defer os.Remove(tempDownloadFile)
131
132 // Save raw favicon data
133 if _, err := io.Copy(downloadFile, limitedBody); err != nil {
134 return fmt.Errorf("failed to save favicon data: %w", err)
135 }
136 downloadFile.Close()
137
138 // Detect if it's an ICO file and convert with ffmpeg if needed
139 if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
140 return fmt.Errorf("failed to convert favicon: %w", err)
141 }
142
143 return nil
144}
145
146// convertToPNG converts a favicon file to PNG format
147func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error {
148 // Try standard Go image decoder first for all formats (PNG, JPEG, GIF)
149 if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
150 log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
151
152 // Apply dark theme improvements
153 img = invertIfTooDark(img)
154
155 // Save as PNG
156 return fc.savePNG(img, outputPath)
157 }
158
159 // Standard decode failed, try go-ico decoder for ICO files
160 log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
161 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
162}
163
164// tryStandardImageDecode attempts to decode image with Go standard library
165func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) {
166 file, err := os.Open(filePath)
167 if err != nil {
168 return nil, err
169 }
170 defer file.Close()
171
172 img, _, err := image.Decode(file)
173 return img, err
174}
175
176// savePNG saves an image as PNG
177func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
178 tempFile := outputPath + ".tmp"
179 file, err := os.Create(tempFile)
180 if err != nil {
181 return fmt.Errorf("failed to create temp file: %w", err)
182 }
183 defer file.Close()
184
185 if err := png.Encode(file, img); err != nil {
186 os.Remove(tempFile)
187 return fmt.Errorf("failed to encode PNG: %w", err)
188 }
189
190 file.Close()
191
192 // Atomic move
193 if err := os.Rename(tempFile, outputPath); err != nil {
194 os.Remove(tempFile)
195 return fmt.Errorf("failed to move PNG to cache: %w", err)
196 }
197
198 return nil
199}
200
201// convertICOWithGoICO converts ICO files to PNG using go-ico library
202func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error {
203 // Open the ICO file
204 file, err := os.Open(inputPath)
205 if err != nil {
206 return fmt.Errorf("failed to open ICO file: %w", err)
207 }
208 defer file.Close()
209
210 // Decode ICO file
211 img, err := ico.Decode(file)
212 if err != nil {
213 return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
214 }
215
216 log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
217
218 // Apply dark theme improvements
219 img = invertIfTooDark(img)
220
221 // Save as PNG
222 return fc.savePNG(img, outputPath)
223}
224
225// CleanOld removes cached favicons older than 30 days
226func (fc *FaviconCache) CleanOld() error {
227 entries, err := os.ReadDir(fc.cacheDir)
228 if err != nil {
229 return err
230 }
231
232 cutoff := time.Now().Add(-30 * 24 * time.Hour)
233
234 for _, entry := range entries {
235 if entry.IsDir() {
236 continue
237 }
238
239 info, err := entry.Info()
240 if err != nil {
241 continue
242 }
243
244 if info.ModTime().Before(cutoff) {
245 os.Remove(filepath.Join(fc.cacheDir, entry.Name()))
246 }
247 }
248
249 return nil
250}
251
252// invertIfTooDark inverts icons that are too dark for dark theme visibility
253// Only inverts truly monochrome dark icons, preserves colorful icons
254func invertIfTooDark(img image.Image) image.Image {
255 bounds := img.Bounds()
256
257 // Sample pixels to calculate statistics
258 var totalR, totalG, totalB uint32
259 var pixelCount uint32
260 var colorVariance uint32
261 var transparentPixels uint32
262
263 // Sample every 4th pixel for performance
264 for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
265 for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
266 r, g, b, a := img.At(x, y).RGBA()
267
268 if a == 0 {
269 transparentPixels++
270 continue
271 }
272
273 if a > 0 { // Only count non-transparent pixels
274 totalR += r
275 totalG += g
276 totalB += b
277 pixelCount++
278
279 // Calculate color variance to detect colorful vs monochrome icons
280 // High variance = colorful (don't invert), low variance = monochrome
281 rNorm := r >> 8 // Convert to 0-255 range
282 gNorm := g >> 8
283 bNorm := b >> 8
284
285 // Simple color variance: difference between max and min color channels
286 maxColor := rNorm
287 if gNorm > maxColor {
288 maxColor = gNorm
289 }
290 if bNorm > maxColor {
291 maxColor = bNorm
292 }
293
294 minColor := rNorm
295 if gNorm < minColor {
296 minColor = gNorm
297 }
298 if bNorm < minColor {
299 minColor = bNorm
300 }
301
302 colorVariance += maxColor - minColor
303 }
304 }
305 }
306
307 if pixelCount == 0 {
308 return img // All transparent
309 }
310
311 // Calculate average brightness (0-65535 range)
312 avgR := totalR / pixelCount
313 avgG := totalG / pixelCount
314 avgB := totalB / pixelCount
315
316 // Calculate perceived brightness using standard luminance formula
317 brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
318
319 // Calculate average color variance (0-255 range)
320 avgColorVariance := colorVariance / pixelCount
321
322 // Calculate transparency ratio
323 totalPixels := pixelCount + transparentPixels
324 transparencyRatio := float64(transparentPixels) / float64(totalPixels)
325
326 // Only invert if:
327 // 1. Icon is very dark (brightness < 20000, low brightness)
328 // 2. AND icon is mostly monochrome (low color variance < 20)
329 // 3. AND icon is not mostly transparent (< 80% transparent)
330 // This makes dark monochrome icons visible on dark themes while preserving colorful icons
331
332 shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
333
334 log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
335 brightness, avgColorVariance, transparencyRatio, shouldInvert)
336
337 if shouldInvert {
338 log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
339 return invertImage(img)
340 }
341
342 return img
343}
344
345// invertImage inverts the colors of an image with edge smoothing
346func invertImage(img image.Image) image.Image {
347 bounds := img.Bounds()
348 inverted := image.NewRGBA(bounds)
349
350 for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
351 for x := bounds.Min.X; x < bounds.Max.X; x++ {
352 r, g, b, a := img.At(x, y).RGBA()
353
354 // Skip fully transparent pixels to avoid noise
355 if a == 0 {
356 inverted.Set(x, y, color.RGBA{A: 0})
357 continue
358 }
359
360 // Convert to 8-bit for easier processing
361 r8 := uint8(r >> 8)
362 g8 := uint8(g >> 8)
363 b8 := uint8(b >> 8)
364 a8 := uint8(a >> 8)
365
366 // Invert RGB but preserve alpha
367 newR := 255 - r8
368 newG := 255 - g8
369 newB := 255 - b8
370
371 // For semi-transparent pixels, blend the inversion more gently
372 // This reduces edge noise by making transitions smoother
373 if a8 < 255 {
374 // Reduce inversion intensity for semi-transparent pixels
375 blendFactor := float64(a8) / 255.0
376 newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor))
377 newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
378 newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
379 }
380
381 inverted.Set(x, y, color.RGBA{
382 R: newR,
383 G: newG,
384 B: newB,
385 A: a8,
386 })
387 }
388 }
389
390 return inverted
391}