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