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 func() {
110 if err := resp.Body.Close(); err != nil {
111 log.Printf("[favicon] failed to close response body: %v", err)
112 }
113 }()
114
115 // Log redirects for debugging
116 if resp.Request.URL.String() != faviconURL {
117 log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String())
118 }
119
120 if resp.StatusCode != http.StatusOK {
121 return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode)
122 }
123
124 // Limit response body size
125 limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max
126
127 // Download to temporary file first
128 tempDownloadFile := cachedPath + ".download"
129 downloadFile, err := os.Create(tempDownloadFile)
130 if err != nil {
131 return fmt.Errorf("failed to create download temp file: %w", err)
132 }
133 defer func() {
134 if err := downloadFile.Close(); err != nil {
135 log.Printf("[favicon] failed to close download file: %v", err)
136 }
137 }()
138 defer func() {
139 if err := os.Remove(tempDownloadFile); err != nil {
140 log.Printf("[favicon] failed to remove temp download file: %v", err)
141 }
142 }()
143
144 // Save raw favicon data
145 if _, err := io.Copy(downloadFile, limitedBody); err != nil {
146 return fmt.Errorf("failed to save favicon data: %w", err)
147 }
148 if err := downloadFile.Close(); err != nil {
149 log.Printf("[favicon] failed to close download file: %v", err)
150 }
151
152 // Detect if it's an ICO file and convert with ffmpeg if needed
153 if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
154 return fmt.Errorf("failed to convert favicon: %w", err)
155 }
156
157 return nil
158}
159
160// convertToPNG converts a favicon file to PNG format
161func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error {
162 // Try standard Go image decoder first for all formats (PNG, JPEG, GIF)
163 if img, err := fc.tryStandardImageDecode(inputPath); err == nil {
164 log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL)
165
166 // Apply dark theme improvements
167 img = invertIfTooDark(img)
168
169 // Save as PNG
170 return fc.savePNG(img, outputPath)
171 }
172
173 // Standard decode failed, try go-ico decoder for ICO files
174 log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL)
175 return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL)
176}
177
178// tryStandardImageDecode attempts to decode image with Go standard library
179func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) {
180 file, err := os.Open(filePath)
181 if err != nil {
182 return nil, err
183 }
184 defer func() {
185 if err := file.Close(); err != nil {
186 log.Printf("[favicon] failed to close file: %v", err)
187 }
188 }()
189
190 img, _, err := image.Decode(file)
191 return img, err
192}
193
194// savePNG saves an image as PNG
195func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
196 tempFile := outputPath + ".tmp"
197 file, err := os.Create(tempFile)
198 if err != nil {
199 return fmt.Errorf("failed to create temp file: %w", err)
200 }
201 defer func() {
202 if err := file.Close(); err != nil {
203 log.Printf("[favicon] failed to close file: %v", err)
204 }
205 }()
206
207 if err := png.Encode(file, img); err != nil {
208 if removeErr := os.Remove(tempFile); removeErr != nil {
209 log.Printf("[favicon] failed to remove temp file: %v", removeErr)
210 }
211 return fmt.Errorf("failed to encode PNG: %w", err)
212 }
213
214 file.Close()
215
216 // Atomic move
217 if err := os.Rename(tempFile, outputPath); err != nil {
218 os.Remove(tempFile)
219 return fmt.Errorf("failed to move PNG to cache: %w", err)
220 }
221
222 return nil
223}
224
225// convertICOWithGoICO converts ICO files to PNG using go-ico library
226func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error {
227 // Open the ICO file
228 file, err := os.Open(inputPath)
229 if err != nil {
230 return fmt.Errorf("failed to open ICO file: %w", err)
231 }
232 defer file.Close()
233
234 // Decode ICO file
235 img, err := ico.Decode(file)
236 if err != nil {
237 return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err)
238 }
239
240 log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL)
241
242 // Apply dark theme improvements
243 img = invertIfTooDark(img)
244
245 // Save as PNG
246 return fc.savePNG(img, outputPath)
247}
248
249// CleanOld removes cached favicons older than 30 days
250func (fc *FaviconCache) CleanOld() error {
251 entries, err := os.ReadDir(fc.cacheDir)
252 if err != nil {
253 return err
254 }
255
256 cutoff := time.Now().Add(-30 * 24 * time.Hour)
257
258 for _, entry := range entries {
259 if entry.IsDir() {
260 continue
261 }
262
263 info, err := entry.Info()
264 if err != nil {
265 continue
266 }
267
268 if info.ModTime().Before(cutoff) {
269 os.Remove(filepath.Join(fc.cacheDir, entry.Name()))
270 }
271 }
272
273 return nil
274}
275
276// invertIfTooDark inverts icons that are too dark for dark theme visibility
277// Only inverts truly monochrome dark icons, preserves colorful icons
278func invertIfTooDark(img image.Image) image.Image {
279 bounds := img.Bounds()
280
281 // Sample pixels to calculate statistics
282 var totalR, totalG, totalB uint32
283 var pixelCount uint32
284 var colorVariance uint32
285 var transparentPixels uint32
286
287 // Sample every 4th pixel for performance
288 for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 {
289 for x := bounds.Min.X; x < bounds.Max.X; x += 4 {
290 r, g, b, a := img.At(x, y).RGBA()
291
292 if a == 0 {
293 transparentPixels++
294 continue
295 }
296
297 if a > 0 { // Only count non-transparent pixels
298 totalR += r
299 totalG += g
300 totalB += b
301 pixelCount++
302
303 // Calculate color variance to detect colorful vs monochrome icons
304 // High variance = colorful (don't invert), low variance = monochrome
305 rNorm := r >> 8 // Convert to 0-255 range
306 gNorm := g >> 8
307 bNorm := b >> 8
308
309 // Simple color variance: difference between max and min color channels
310 maxColor := rNorm
311 if gNorm > maxColor {
312 maxColor = gNorm
313 }
314 if bNorm > maxColor {
315 maxColor = bNorm
316 }
317
318 minColor := rNorm
319 if gNorm < minColor {
320 minColor = gNorm
321 }
322 if bNorm < minColor {
323 minColor = bNorm
324 }
325
326 colorVariance += maxColor - minColor
327 }
328 }
329 }
330
331 if pixelCount == 0 {
332 return img // All transparent
333 }
334
335 // Calculate average brightness (0-65535 range)
336 avgR := totalR / pixelCount
337 avgG := totalG / pixelCount
338 avgB := totalB / pixelCount
339
340 // Calculate perceived brightness using standard luminance formula
341 brightness := (299*avgR + 587*avgG + 114*avgB) / 1000
342
343 // Calculate average color variance (0-255 range)
344 avgColorVariance := colorVariance / pixelCount
345
346 // Calculate transparency ratio
347 totalPixels := pixelCount + transparentPixels
348 transparencyRatio := float64(transparentPixels) / float64(totalPixels)
349
350 // Only invert if:
351 // 1. Icon is very dark (brightness < 20000, low brightness)
352 // 2. AND icon is mostly monochrome (low color variance < 20)
353 // 3. AND icon is not mostly transparent (< 80% transparent)
354 // This makes dark monochrome icons visible on dark themes while preserving colorful icons
355
356 shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8
357
358 log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t",
359 brightness, avgColorVariance, transparencyRatio, shouldInvert)
360
361 if shouldInvert {
362 log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility")
363 return invertImage(img)
364 }
365
366 return img
367}
368
369// invertImage inverts the colors of an image with edge smoothing
370func invertImage(img image.Image) image.Image {
371 bounds := img.Bounds()
372 inverted := image.NewRGBA(bounds)
373
374 for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
375 for x := bounds.Min.X; x < bounds.Max.X; x++ {
376 r, g, b, a := img.At(x, y).RGBA()
377
378 // Skip fully transparent pixels to avoid noise
379 if a == 0 {
380 inverted.Set(x, y, color.RGBA{A: 0})
381 continue
382 }
383
384 // Convert to 8-bit for easier processing
385 r8 := uint8(r >> 8)
386 g8 := uint8(g >> 8)
387 b8 := uint8(b >> 8)
388 a8 := uint8(a >> 8)
389
390 // Invert RGB but preserve alpha
391 newR := 255 - r8
392 newG := 255 - g8
393 newB := 255 - b8
394
395 // For semi-transparent pixels, blend the inversion more gently
396 // This reduces edge noise by making transitions smoother
397 if a8 < 255 {
398 // Reduce inversion intensity for semi-transparent pixels
399 blendFactor := float64(a8) / 255.0
400 newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor))
401 newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor))
402 newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor))
403 }
404
405 inverted.Set(x, y, color.RGBA{
406 R: newR,
407 G: newG,
408 B: newB,
409 A: a8,
410 })
411 }
412 }
413
414 return inverted
415}