1// Package adapter provides UI-layer adapters that bridge domain services to GTK.
2package adapter
3
4import (
5 "context"
6 "sync"
7
8 domainurl "github.com/bnema/dumber/internal/domain/url"
9 "github.com/bnema/dumber/internal/infrastructure/favicon"
10 "github.com/bnema/dumber/internal/infrastructure/webkit"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/jwijenbergh/puregotk/v4/gdk"
13 "github.com/jwijenbergh/puregotk/v4/gio"
14 "github.com/jwijenbergh/puregotk/v4/glib"
15 "github.com/rs/zerolog"
16)
17
18// FaviconAdapter bridges the domain FaviconService to GTK by providing
19// gdk.Texture conversion and WebKit FaviconDatabase integration.
20type FaviconAdapter struct {
21 service *favicon.Service
22 faviconDB *webkit.FaviconDatabase
23 textureCache map[string]*gdk.Texture
24 mu sync.RWMutex
25 warnMu sync.Mutex
26 warnCounts map[string]int
27}
28
29type warningLogFunc func(log *zerolog.Logger, err error)
30
31// NewFaviconAdapter creates a new FaviconAdapter.
32// The faviconDB can be nil if WebKit favicon database is not available.
33func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
34 return &FaviconAdapter{
35 service: service,
36 faviconDB: faviconDB,
37 textureCache: make(map[string]*gdk.Texture),
38 warnCounts: make(map[string]int),
39 }
40}
41
42// GetTexture returns a cached texture for the domain, or nil if not cached.
43func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
44 if domain == "" {
45 return nil
46 }
47
48 a.mu.RLock()
49 texture := a.textureCache[domain]
50 a.mu.RUnlock()
51 return texture
52}
53
54// GetTextureByURL returns a cached texture by extracting domain from URL.
55// For internal dumb:// URLs, returns the app logo texture.
56func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
57 // Handle internal dumb:// scheme URLs
58 if favicon.IsInternalURL(pageURL) {
59 return a.GetTexture(favicon.InternalDomain)
60 }
61 domain := domainurl.ExtractDomain(pageURL)
62 return a.GetTexture(domain)
63}
64
65// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
66// The callback is invoked on the GTK main thread with the texture (or nil).
67// For internal dumb:// URLs, returns the app logo texture.
68func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
69 if callback == nil {
70 return
71 }
72
73 log := logging.FromContext(ctx)
74
75 // Handle internal dumb:// scheme URLs - use app logo
76 if favicon.IsInternalURL(pageURL) {
77 texture := a.getOrCreateLogoTexture()
78 callback(texture)
79 return
80 }
81
82 domain := domainurl.ExtractDomain(pageURL)
83
84 // Check texture cache first
85 if texture := a.GetTexture(domain); texture != nil {
86 // Ensure sized PNG exists for CLI tools (async, idempotent)
87 go a.ensureSizedPNG(ctx, domain)
88 callback(texture)
89 return
90 }
91
92 // Check service cache (memory + disk) and convert to texture
93 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
94 texture := bytesToTexture(data)
95 if texture != nil {
96 a.setTexture(domain, texture)
97 // Ensure sized PNG exists for CLI tools (async, idempotent)
98 go a.ensureSizedPNG(ctx, domain)
99 callback(texture)
100 return
101 }
102 }
103
104 // Helper to invoke callback on main thread
105 invokeCallback := func(texture *gdk.Texture) {
106 cb := glib.SourceFunc(func(uintptr) bool {
107 callback(texture)
108 return false
109 })
110 glib.IdleAdd(&cb, 0)
111 }
112
113 // Helper to fetch via service and invoke callback
114 fetchViaService := func() {
115 go func() {
116 data, err := a.service.Get(ctx, domain)
117 if err != nil || len(data) == 0 {
118 invokeCallback(nil)
119 return
120 }
121
122 texture := bytesToTexture(data)
123 if texture != nil {
124 a.setTexture(domain, texture)
125 }
126 invokeCallback(texture)
127 }()
128 }
129
130 // Try WebKit FaviconDatabase if available
131 if a.faviconDB != nil {
132 asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
133 if resultPtr == 0 {
134 log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
135 fetchViaService()
136 return
137 }
138
139 result := &gio.AsyncResultBase{Ptr: resultPtr}
140 texture, err := a.faviconDB.GetFaviconFinish(result)
141 if err != nil || texture == nil {
142 log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
143 fetchViaService()
144 return
145 }
146
147 // Found in WebKit database
148 a.setTexture(domain, texture)
149 invokeCallback(texture)
150
151 // Ensure disk cache is populated (async)
152 go a.service.EnsureDiskCache(ctx, domain)
153 })
154
155 a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
156 return
157 }
158
159 // No WebKit DB, fetch via service directly
160 fetchViaService()
161}
162
163// StoreFromWebKit stores a favicon texture received from WebKit signals.
164// Also ensures the favicon is persisted to disk cache via the service.
165func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
166 if texture == nil {
167 return
168 }
169
170 domain := domainurl.ExtractDomain(pageURL)
171 if domain == "" {
172 return
173 }
174
175 // Store in texture cache
176 a.setTexture(domain, texture)
177
178 // Export as PNG and ensure disk cache
179 a.saveFaviconToDisk(ctx, domain, texture)
180}
181
182// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
183// Used when redirects occur (e.g., example.com → www.example.com).
184func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
185 ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
186) {
187 a.StoreFromWebKit(ctx, currentURL, texture)
188
189 // Also store under original URL domain if different
190 if originURL != "" && originURL != currentURL {
191 originDomain := domainurl.ExtractDomain(originURL)
192 currentDomain := domainurl.ExtractDomain(currentURL)
193 if originDomain != "" && originDomain != currentDomain {
194 a.setTexture(originDomain, texture)
195 a.saveFaviconToDisk(ctx, originDomain, texture)
196 }
197 }
198}
199
200// saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
201// Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
202func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
203 log := logging.FromContext(ctx)
204 cacheDirWarnKey := "cache-dir"
205 savePNGWarnKey := "save-png:" + domain
206 sizedPNGWarnKey := "sized-png:" + domain
207
208 // Check if PNG save is needed before scheduling idle callback
209 pngPath := a.service.DiskPathPNG(domain)
210 needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain)
211
212 if needsPNGSave {
213 // Ensure cache directory exists (cheap check, do it now)
214 if err := a.service.EnsureCacheDir(); err != nil {
215 a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) {
216 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir")
217 })
218 needsPNGSave = false
219 } else {
220 a.clearWarningDedup(cacheDirWarnKey)
221 }
222 }
223
224 if needsPNGSave {
225 // Schedule PNG save for when GTK main loop is idle (non-blocking)
226 // texture.SaveToPng must run on main thread but we defer it to avoid blocking
227 cb := glib.SourceFunc(func(_ uintptr) bool {
228 if ok := texture.SaveToPng(pngPath); !ok {
229 a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) {
230 log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
231 })
232 } else {
233 a.clearWarningDedup(savePNGWarnKey)
234 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
235
236 // Create normalized sized copy for dmenu/fuzzel (async)
237 go func() {
238 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
239 a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
240 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
241 })
242 } else {
243 a.clearWarningDedup(sizedPNGWarnKey)
244 log.Debug().Str("domain", domain).Msg("created sized PNG")
245 }
246 }()
247 }
248 return false // don't repeat
249 })
250 glib.IdleAdd(&cb, 0)
251 }
252
253 // Ensure disk cache is populated (async, in background)
254 go a.service.EnsureDiskCache(ctx, domain)
255}
256
257// PreloadFromCache attempts to load a favicon from cache without fetching.
258// Returns the texture if found in memory or disk cache, nil otherwise.
259func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
260 domain := domainurl.ExtractDomain(pageURL)
261 if domain == "" {
262 return nil
263 }
264
265 // Check texture cache
266 if texture := a.GetTexture(domain); texture != nil {
267 return texture
268 }
269
270 // Check service cache (memory + disk)
271 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
272 texture := bytesToTexture(data)
273 if texture != nil {
274 a.setTexture(domain, texture)
275 return texture
276 }
277 }
278
279 return nil
280}
281
282// Service returns the underlying favicon service.
283// Used by CLI components that need disk paths.
284func (a *FaviconAdapter) Service() *favicon.Service {
285 return a.service
286}
287
288// Close shuts down the adapter and underlying service.
289func (a *FaviconAdapter) Close() {
290 a.service.Close()
291}
292
293// Clear removes all entries from the texture cache.
294func (a *FaviconAdapter) Clear() {
295 a.mu.Lock()
296 a.textureCache = make(map[string]*gdk.Texture)
297 a.mu.Unlock()
298}
299
300// Size returns the number of entries in the texture cache.
301func (a *FaviconAdapter) Size() int {
302 a.mu.RLock()
303 size := len(a.textureCache)
304 a.mu.RUnlock()
305 return size
306}
307
308// setTexture stores a texture in the cache.
309func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
310 if domain == "" || texture == nil {
311 return
312 }
313 a.mu.Lock()
314 a.textureCache[domain] = texture
315 a.mu.Unlock()
316}
317
318// ensureSizedPNG ensures the sized PNG exists for CLI tools.
319// This is idempotent - it only creates the file if it doesn't exist.
320func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
321 if domain == "" {
322 return
323 }
324
325 log := logging.FromContext(ctx)
326
327 hasPNG := a.service.HasPNGOnDisk(domain)
328 hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
329
330 log.Debug().
331 Str("domain", domain).
332 Bool("has_png", hasPNG).
333 Bool("has_sized", hasSized).
334 Msg("checking sized PNG status")
335
336 // Only create if original PNG exists but sized version doesn't
337 if hasPNG && !hasSized {
338 log.Debug().Str("domain", domain).Msg("creating sized PNG")
339 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
340 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
341 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
342 })
343 } else {
344 a.clearWarningDedup("sized-png:" + domain)
345 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
346 }
347 }
348}
349
350func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) {
351 a.warnMu.Lock()
352 defer a.warnMu.Unlock()
353
354 count := a.warnCounts[key] + 1
355 a.warnCounts[key] = count
356
357 return count == 1, count - 1
358}
359
360func (a *FaviconAdapter) clearWarningDedup(key string) {
361 a.warnMu.Lock()
362 delete(a.warnCounts, key)
363 a.warnMu.Unlock()
364}
365
366func (a *FaviconAdapter) logWarningDedup(
367 ctx context.Context,
368 key string,
369 err error,
370 warnFn warningLogFunc,
371) {
372 log := logging.FromContext(ctx)
373 logWarning, suppressed := a.shouldLogWarningDedup(key)
374 if logWarning {
375 warnFn(log, err)
376 return
377 }
378
379 if suppressed == 1 {
380 event := log.Debug()
381 if err != nil {
382 event = event.Err(err)
383 }
384 event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning")
385 }
386}
387
388// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
389// The texture is cached under the InternalDomain key.
390func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
391 // Check cache first
392 if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
393 return texture
394 }
395
396 // Create texture from logo bytes
397 logoBytes := favicon.GetLogoBytes()
398 texture := bytesToTexture(logoBytes)
399 if texture != nil {
400 a.setTexture(favicon.InternalDomain, texture)
401 }
402 return texture
403}
404
405// bytesToTexture converts raw favicon bytes to a GDK texture.
406func bytesToTexture(data []byte) *gdk.Texture {
407 if len(data) == 0 {
408 return nil
409 }
410
411 bytes := glib.NewBytes(data, uint(len(data)))
412 if bytes == nil {
413 return nil
414 }
415
416 texture, err := gdk.NewTextureFromBytes(bytes)
417 if err != nil {
418 return nil
419 }
420
421 return texture
422}