1package favicon
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "sync"
8
9 domainurl "github.com/bnema/dumber/internal/domain/url"
10 "github.com/bnema/dumber/internal/logging"
11)
12
13const (
14 // diskWriteBufferSize defines the capacity of the favicon write channel.
15 diskWriteBufferSize = 100
16 // File permissions for favicon cache.
17 diskCacheDirPerm = 0750
18 diskCacheFilePerm = 0600
19)
20
21// diskWrite represents a favicon to be written to disk asynchronously.
22type diskWrite struct {
23 domain string
24 data []byte
25}
26
27// Cache provides multi-tier favicon caching (memory + disk).
28type Cache struct {
29 memCache map[string][]byte
30 diskDir string
31 writeChan chan diskWrite
32 closeOnce sync.Once
33 mu sync.RWMutex
34}
35
36// NewCache creates a new favicon cache.
37// If diskDir is empty, only in-memory caching is used.
38func NewCache(diskDir string) *Cache {
39 c := &Cache{
40 memCache: make(map[string][]byte),
41 diskDir: diskDir,
42 writeChan: make(chan diskWrite, diskWriteBufferSize),
43 }
44
45 // Start background writer goroutine if disk caching is enabled
46 if diskDir != "" {
47 go c.diskWriter()
48 }
49
50 return c
51}
52
53// Get retrieves favicon bytes for a domain.
54// Checks memory cache first, then disk cache.
55// Returns the bytes and true if found, nil and false otherwise.
56func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) {
57 if domain == "" {
58 return nil, false
59 }
60 log := logging.FromContext(ctx)
61
62 // Check memory cache first
63 c.mu.RLock()
64 data, ok := c.memCache[domain]
65 c.mu.RUnlock()
66 if ok {
67 log.Debug().
68 Str("domain", domain).
69 Int("bytes", len(data)).
70 Msg("favicon: Cache.Get memory hit")
71 return data, true
72 }
73
74 // Check disk cache
75 data = c.loadFromDisk(domain)
76 if data != nil {
77 // Populate memory cache
78 c.mu.Lock()
79 c.memCache[domain] = data
80 c.mu.Unlock()
81 log.Debug().
82 Str("domain", domain).
83 Int("bytes", len(data)).
84 Msg("favicon: Cache.Get disk hit")
85 return data, true
86 }
87
88 log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss")
89 return nil, false
90}
91
92// Set stores favicon bytes for a domain.
93// Writes to memory cache immediately and queues async disk write.
94func (c *Cache) Set(ctx context.Context, domain string, data []byte) {
95 if domain == "" || len(data) == 0 {
96 return
97 }
98 logging.FromContext(ctx).Debug().
99 Str("domain", domain).
100 Int("bytes", len(data)).
101 Msg("favicon: Cache.Set")
102
103 // Write to memory cache
104 c.mu.Lock()
105 c.memCache[domain] = data
106 c.mu.Unlock()
107
108 // Queue async disk write
109 c.queueDiskWrite(domain, data)
110}
111
112// DiskPath returns the filesystem path for a domain's cached favicon (ICO format).
113// Returns empty string if disk caching is disabled or domain is empty.
114func (c *Cache) DiskPath(domain string) string {
115 if c.diskDir == "" || domain == "" {
116 return ""
117 }
118 filename := domainurl.SanitizeDomainForFilename(domain)
119 return filepath.Join(c.diskDir, filename)
120}
121
122// DiskPathPNG returns the filesystem path for a domain's PNG favicon.
123// PNG format is required by external tools like rofi/fuzzel.
124// Returns empty string if disk caching is disabled or domain is empty.
125func (c *Cache) DiskPathPNG(domain string) string {
126 if c.diskDir == "" || domain == "" {
127 return ""
128 }
129 filename := domainurl.SanitizeDomainForPNG(domain)
130 return filepath.Join(c.diskDir, filename)
131}
132
133// HasPNGOnDisk checks if a PNG favicon exists on disk for the given domain.
134func (c *Cache) HasPNGOnDisk(domain string) bool {
135 path := c.DiskPathPNG(domain)
136 if path == "" {
137 return false
138 }
139 _, err := os.Stat(path)
140 return err == nil
141}
142
143// DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
144// Used for normalized favicon export (e.g., 32x32) for CLI tools like rofi/fuzzel.
145// Returns empty string if disk caching is disabled or domain is empty.
146func (c *Cache) DiskPathPNGSized(domain string, size int) string {
147 if c.diskDir == "" || domain == "" {
148 return ""
149 }
150 filename := domainurl.SanitizeDomainForPNGSized(domain, size)
151 return filepath.Join(c.diskDir, filename)
152}
153
154// HasPNGSizedOnDisk checks if a sized PNG favicon exists on disk for the given domain.
155func (c *Cache) HasPNGSizedOnDisk(domain string, size int) bool {
156 path := c.DiskPathPNGSized(domain, size)
157 if path == "" {
158 return false
159 }
160 _, err := os.Stat(path)
161 return err == nil
162}
163
164// WritePNG writes raw PNG data to disk for a domain.
165// Used by UI layer to export WebKit textures for CLI tools.
166func (c *Cache) WritePNG(domain string, pngData []byte) {
167 if c.diskDir == "" || len(pngData) == 0 || domain == "" {
168 return
169 }
170
171 // Ensure directory exists
172 if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
173 return
174 }
175
176 filename := domainurl.SanitizeDomainForPNG(domain)
177 finalPath := filepath.Join(c.diskDir, filename)
178 tempPath := finalPath + ".tmp"
179
180 // Write to temp file
181 if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
182 return
183 }
184
185 // Atomic rename
186 if err := os.Rename(tempPath, finalPath); err != nil {
187 _ = os.Remove(tempPath)
188 }
189}
190
191// WritePNGSized writes raw PNG data to disk for a domain at a specific size.
192// Used for internal favicons that are pre-sized (e.g., embedded app logo).
193func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) {
194 if c.diskDir == "" || len(pngData) == 0 || domain == "" {
195 return
196 }
197
198 // Ensure directory exists
199 if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
200 return
201 }
202
203 filename := domainurl.SanitizeDomainForPNGSized(domain, size)
204 finalPath := filepath.Join(c.diskDir, filename)
205 tempPath := finalPath + ".tmp"
206
207 // Write to temp file
208 if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
209 return
210 }
211
212 // Atomic rename
213 if err := os.Rename(tempPath, finalPath); err != nil {
214 _ = os.Remove(tempPath)
215 }
216}
217
218// HasOnDisk checks if a favicon exists on disk for the given domain.
219func (c *Cache) HasOnDisk(domain string) bool {
220 path := c.DiskPath(domain)
221 if path == "" {
222 return false
223 }
224 _, err := os.Stat(path)
225 return err == nil
226}
227
228// Close shuts down the background writer goroutine.
229func (c *Cache) Close() {
230 c.closeOnce.Do(func() {
231 if c.writeChan != nil {
232 close(c.writeChan)
233 }
234 })
235}
236
237// EnsureDir ensures the cache directory exists on disk.
238// Call this before using DiskPathPNG with external save functions.
239func (c *Cache) EnsureDir() error {
240 if c.diskDir == "" {
241 return nil
242 }
243 return os.MkdirAll(c.diskDir, diskCacheDirPerm)
244}
245
246// Clear removes all entries from the in-memory cache.
247func (c *Cache) Clear() {
248 c.mu.Lock()
249 c.memCache = make(map[string][]byte)
250 c.mu.Unlock()
251}
252
253// Size returns the number of entries in the in-memory cache.
254func (c *Cache) Size() int {
255 c.mu.RLock()
256 size := len(c.memCache)
257 c.mu.RUnlock()
258 return size
259}
260
261// loadFromDisk attempts to load favicon bytes from disk cache.
262func (c *Cache) loadFromDisk(domain string) []byte {
263 path := c.DiskPath(domain)
264 if path == "" {
265 return nil
266 }
267
268 data, err := os.ReadFile(path)
269 if err != nil {
270 return nil
271 }
272
273 if len(data) == 0 {
274 return nil
275 }
276
277 return data
278}
279
280// writeToDisk atomically writes favicon data to disk.
281func (c *Cache) writeToDisk(domain string, data []byte) {
282 if c.diskDir == "" || len(data) == 0 {
283 return
284 }
285
286 // Ensure directory exists
287 if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
288 return
289 }
290
291 filename := domainurl.SanitizeDomainForFilename(domain)
292 finalPath := filepath.Join(c.diskDir, filename)
293 tempPath := finalPath + ".tmp"
294
295 // Write to temp file
296 if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
297 return
298 }
299
300 // Atomic rename
301 if err := os.Rename(tempPath, finalPath); err != nil {
302 _ = os.Remove(tempPath)
303 }
304}
305
306// queueDiskWrite sends favicon data to be written asynchronously.
307func (c *Cache) queueDiskWrite(domain string, data []byte) {
308 if c.diskDir == "" {
309 return
310 }
311 select {
312 case c.writeChan <- diskWrite{domain: domain, data: data}:
313 // queued successfully
314 default:
315 // channel full, skip write
316 }
317}
318
319// diskWriter processes async write requests.
320func (c *Cache) diskWriter() {
321 for write := range c.writeChan {
322 c.writeToDisk(write.domain, write.data)
323 }
324}