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