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