1// Package adapter provides UI-layer adapters that bridge domain services to GTK.
2package adapter
3
4import (
5 "context"
6 "os"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/port"
11 domainurl "github.com/bnema/dumber/internal/domain/url"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk/v4/gdk"
14 "github.com/bnema/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 port.FaviconService
22 faviconDB port.FaviconDatabase
23 textureCache map[string]*gdk.Texture
24 mu sync.RWMutex
25 warnMu sync.Mutex
26 warnCounts map[string]int
27
28 // App-logo / internal-URL helpers (injected to decouple from infrastructure/favicon)
29 isInternalURL func(url string) bool
30 internalDomain string
31 normalizedIconSize int
32 getLogoBytes func() []byte
33 disabled bool
34}
35
36const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS"
37
38type warningLogFunc func(log *zerolog.Logger, err error)
39
40type textureResult struct {
41 texture *gdk.Texture
42}
43
44// FaviconAdapterConfig holds optional configuration for FaviconAdapter.
45// Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
46type FaviconAdapterConfig struct {
47 // IsInternalURL reports whether a URL uses the internal app scheme (e.g. dumb://).
48 IsInternalURL func(url string) bool
49 // InternalDomain is the pseudo-domain used to cache the app logo favicon.
50 InternalDomain string
51 // NormalizedIconSize is the target size for dmenu/fuzzel icons (0 = skip).
52 NormalizedIconSize int
53 // GetLogoBytes returns the raw bytes of the app logo (nil = skip logo favicon).
54 GetLogoBytes func() []byte
55}
56
57// NewFaviconAdapter creates a new FaviconAdapter.
58// The faviconDB can be nil if a favicon database is not available.
59func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
60 disabled := os.Getenv(disableFaviconsEnv) == "1"
61 return &FaviconAdapter{
62 service: service,
63 faviconDB: faviconDB,
64 textureCache: make(map[string]*gdk.Texture),
65 warnCounts: make(map[string]int),
66 isInternalURL: cfg.IsInternalURL,
67 internalDomain: cfg.InternalDomain,
68 normalizedIconSize: cfg.NormalizedIconSize,
69 getLogoBytes: cfg.GetLogoBytes,
70 disabled: disabled,
71 }
72}
73
74// GetTexture returns a cached texture for the domain, or nil if not cached.
75func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
76 if domain == "" {
77 return nil
78 }
79
80 a.mu.RLock()
81 texture := a.textureCache[domain]
82 a.mu.RUnlock()
83 return texture
84}
85
86// GetTextureByURL returns a cached texture by extracting domain from URL.
87// For internal dumb:// URLs, returns the app logo texture.
88func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
89 // Handle internal dumb:// scheme URLs
90 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
91 return a.GetTexture(a.internalDomain)
92 }
93 domain := domainurl.ExtractDomain(pageURL)
94 return a.GetTexture(domain)
95}
96
97// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
98// The callback is invoked on the GTK main thread with the texture (or nil).
99// For internal dumb:// URLs, returns the app logo texture.
100func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
101 if callback == nil {
102 return
103 }
104
105 log := logging.FromContext(ctx)
106 if a.disabled {
107 log.Warn().
108 Str("env", disableFaviconsEnv).
109 Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation")
110 callback(nil)
111 return
112 }
113
114 // Handle internal dumb:// scheme URLs - use app logo
115 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
116 callback(a.getOrCreateLogoTexture(ctx))
117 return
118 }
119
120 domain := domainurl.ExtractDomain(pageURL)
121 log.Debug().
122 Str("page_url", pageURL).
123 Str("domain", domain).
124 Msg("favicon: GetOrFetch begin")
125
126 // Check texture cache first
127 if texture := a.GetTexture(domain); texture != nil {
128 log.Debug().Str("domain", domain).Msg("favicon: texture cache hit")
129 // Ensure sized PNG exists for CLI tools (async, idempotent)
130 go a.ensureSizedPNG(ctx, domain)
131 callback(texture)
132 return
133 }
134
135 // Check service cache (memory + disk) and convert to texture
136 if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
137 log.Debug().
138 Str("domain", domain).
139 Int("bytes", len(data)).
140 Msg("favicon: service cache hit")
141 texture := a.textureFromBytesOnGTK(ctx, data)
142 if texture != nil {
143 a.setTexture(domain, texture)
144 // Ensure sized PNG exists for CLI tools (async, idempotent)
145 go a.ensureSizedPNG(ctx, domain)
146 callback(texture)
147 return
148 }
149 }
150
151 // Try engine FaviconDatabase if available
152 if a.faviconDB != nil {
153 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154 a.handleEngineFaviconDBTexture(ctx, domain, callback, tex)
155 })
156 return
157 }
158
159 // No WebKit DB, fetch via service directly
160 log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service")
161 a.fetchViaService(ctx, domain, callback)
162}
163
164func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
165 log := logging.FromContext(ctx)
166 log.Debug().
167 Str("domain", domain).
168 Bool("texture_nil", texture == nil).
169 Msg("favicon: scheduling callback on GTK")
170 cb := glib.SourceFunc(func(uintptr) bool {
171 log.Debug().
172 Str("domain", domain).
173 Bool("texture_nil", texture == nil).
174 Msg("favicon: callback executing on GTK")
175 callback(texture)
176 return false
177 })
178 glib.IdleAdd(&cb, 0)
179}
180
181func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
182 go func() {
183 log := logging.FromContext(ctx)
184 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
185 data, err := a.service.Get(ctx, domain)
186 if err != nil || len(data) == 0 {
187 log.Debug().
188 Err(err).
189 Str("domain", domain).
190 Int("bytes", len(data)).
191 Msg("favicon: fetchViaService empty/error result")
192 a.invokeCallbackOnGTK(ctx, domain, callback, nil)
193 return
194 }
195
196 log.Debug().
197 Str("domain", domain).
198 Int("bytes", len(data)).
199 Msg("favicon: fetchViaService fetched data")
200 texture := a.textureFromBytesOnGTK(ctx, data)
201 if texture != nil {
202 a.setTexture(domain, texture)
203 }
204 log.Debug().
205 Str("domain", domain).
206 Bool("texture_nil", texture == nil).
207 Msg("favicon: fetchViaService end")
208 a.invokeCallbackOnGTK(ctx, domain, callback, texture)
209 }()
210}
211
212func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) {
213 log := logging.FromContext(ctx)
214 log.Debug().
215 Str("domain", domain).
216 Bool("texture_nil", tex == nil).
217 Msg("favicon: engine db callback")
218 if tex == nil {
219 log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
220 a.fetchViaService(ctx, domain, callback)
221 return
222 }
223 // Convert port.Texture to *gdk.Texture
224 gdkTex, ok := tex.(*gdk.Texture)
225 if !ok {
226 a.fetchViaService(ctx, domain, callback)
227 return
228 }
229 a.setTexture(domain, gdkTex)
230 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
231 a.saveFaviconToDisk(ctx, domain, gdkTex)
232}
233
234// StoreFromWebKit stores a favicon texture received from WebKit signals.
235// Also ensures the favicon is persisted to disk cache via the service.
236func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
237 if a.disabled {
238 logging.FromContext(ctx).Debug().
239 Str("page_url", pageURL).
240 Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled")
241 return
242 }
243 if texture == nil {
244 return
245 }
246
247 domain := domainurl.ExtractDomain(pageURL)
248 if domain == "" {
249 return
250 }
251
252 // Store in texture cache
253 a.setTexture(domain, texture)
254
255 // Export as PNG and ensure disk cache
256 a.saveFaviconToDisk(ctx, domain, texture)
257}
258
259// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
260// Used when redirects occur (e.g., example.com → www.example.com).
261func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
262 ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
263) {
264 a.StoreFromWebKit(ctx, currentURL, texture)
265
266 // Also store under original URL domain if different
267 if originURL != "" && originURL != currentURL {
268 originDomain := domainurl.ExtractDomain(originURL)
269 currentDomain := domainurl.ExtractDomain(currentURL)
270 if originDomain != "" && originDomain != currentDomain {
271 a.setTexture(originDomain, texture)
272 a.saveFaviconToDisk(ctx, originDomain, texture)
273 }
274 }
275}
276
277// saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
278// Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
279func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
280 log := logging.FromContext(ctx)
281 cacheDirWarnKey := "cache-dir"
282 savePNGWarnKey := "save-png:" + domain
283 sizedPNGWarnKey := "sized-png:" + domain
284
285 // Check if PNG save is needed before scheduling idle callback
286 pngPath := a.service.DiskPathPNG(domain)
287 needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain)
288
289 if needsPNGSave {
290 // Ensure cache directory exists (cheap check, do it now)
291 if err := a.service.EnsureCacheDir(); err != nil {
292 a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) {
293 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir")
294 })
295 needsPNGSave = false
296 } else {
297 a.clearWarningDedup(cacheDirWarnKey)
298 }
299 }
300
301 if needsPNGSave {
302 // Schedule PNG save for when GTK main loop is idle (non-blocking)
303 // texture.SaveToPng must run on main thread but we defer it to avoid blocking
304 cb := glib.SourceFunc(func(_ uintptr) bool {
305 if ok := texture.SaveToPng(pngPath); !ok {
306 a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) {
307 log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
308 })
309 } else {
310 a.clearWarningDedup(savePNGWarnKey)
311 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
312
313 // Create normalized sized copy for dmenu/fuzzel (async, only if size is set)
314 if a.normalizedIconSize > 0 {
315 go func() {
316 if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
317 a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
318 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
319 })
320 } else {
321 a.clearWarningDedup(sizedPNGWarnKey)
322 log.Debug().Str("domain", domain).Msg("created sized PNG")
323 }
324 }()
325 }
326 }
327 return false // don't repeat
328 })
329 glib.IdleAdd(&cb, 0)
330 }
331
332 // Ensure disk cache is populated (async, in background)
333 go a.service.EnsureDiskCache(ctx, domain)
334}
335
336// PreloadFromCache attempts to load a favicon from cache without fetching.
337// Returns the texture if found in memory or disk cache, nil otherwise.
338func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture {
339 if a.disabled {
340 logging.FromContext(ctx).Debug().
341 Str("page_url", pageURL).
342 Str("env", disableFaviconsEnv).
343 Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
344 return nil
345 }
346 domain := domainurl.ExtractDomain(pageURL)
347 if domain == "" {
348 return nil
349 }
350 log := logging.FromContext(ctx)
351 log.Debug().
352 Str("page_url", pageURL).
353 Str("domain", domain).
354 Msg("favicon: PreloadFromCache begin")
355
356 // Check texture cache
357 if texture := a.GetTexture(domain); texture != nil {
358 log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit")
359 return texture
360 }
361
362 // Check service cache (memory + disk)
363 if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
364 log.Debug().
365 Str("domain", domain).
366 Int("bytes", len(data)).
367 Msg("favicon: PreloadFromCache service hit")
368 texture := a.textureFromBytesOnGTK(ctx, data)
369 if texture != nil {
370 a.setTexture(domain, texture)
371 return texture
372 }
373 }
374
375 log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss")
376 return nil
377}
378
379// Service returns the underlying favicon service.
380// Used by CLI components that need disk paths.
381func (a *FaviconAdapter) Service() port.FaviconService {
382 return a.service
383}
384
385// Close shuts down the adapter and underlying service.
386func (a *FaviconAdapter) Close() {
387 a.service.Close()
388}
389
390// Clear removes all entries from the texture cache.
391func (a *FaviconAdapter) Clear() {
392 a.mu.Lock()
393 a.textureCache = make(map[string]*gdk.Texture)
394 a.mu.Unlock()
395}
396
397// Size returns the number of entries in the texture cache.
398func (a *FaviconAdapter) Size() int {
399 a.mu.RLock()
400 size := len(a.textureCache)
401 a.mu.RUnlock()
402 return size
403}
404
405// setTexture stores a texture in the cache.
406func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
407 if domain == "" || texture == nil {
408 return
409 }
410 a.mu.Lock()
411 a.textureCache[domain] = texture
412 a.mu.Unlock()
413}
414
415// ensureSizedPNG ensures the sized PNG exists for CLI tools.
416// This is idempotent - it only creates the file if it doesn't exist.
417func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
418 if domain == "" {
419 return
420 }
421
422 log := logging.FromContext(ctx)
423
424 hasPNG := a.service.HasPNGOnDisk(domain)
425 hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize)
426
427 log.Debug().
428 Str("domain", domain).
429 Bool("has_png", hasPNG).
430 Bool("has_sized", hasSized).
431 Msg("checking sized PNG status")
432
433 // Only create if original PNG exists but sized version doesn't
434 if hasPNG && !hasSized {
435 log.Debug().Str("domain", domain).Msg("creating sized PNG")
436 if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
437 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
438 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
439 })
440 } else {
441 a.clearWarningDedup("sized-png:" + domain)
442 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
443 }
444 }
445}
446
447func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) {
448 a.warnMu.Lock()
449 defer a.warnMu.Unlock()
450
451 count := a.warnCounts[key] + 1
452 a.warnCounts[key] = count
453
454 return count == 1, count - 1
455}
456
457func (a *FaviconAdapter) clearWarningDedup(key string) {
458 a.warnMu.Lock()
459 delete(a.warnCounts, key)
460 a.warnMu.Unlock()
461}
462
463func (a *FaviconAdapter) logWarningDedup(
464 ctx context.Context,
465 key string,
466 err error,
467 warnFn warningLogFunc,
468) {
469 log := logging.FromContext(ctx)
470 logWarning, suppressed := a.shouldLogWarningDedup(key)
471 if logWarning {
472 warnFn(log, err)
473 return
474 }
475
476 if suppressed == 1 {
477 event := log.Debug()
478 if err != nil {
479 event = event.Err(err)
480 }
481 event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning")
482 }
483}
484
485// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
486// The texture is cached under the internalDomain key.
487func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture {
488 if a.internalDomain == "" || a.getLogoBytes == nil {
489 return nil
490 }
491
492 // Check cache first
493 if texture := a.GetTexture(a.internalDomain); texture != nil {
494 return texture
495 }
496
497 // Create texture from logo bytes
498 logoBytes := a.getLogoBytes()
499 texture := a.textureFromBytesOnGTK(ctx, logoBytes)
500 if texture != nil {
501 a.setTexture(a.internalDomain, texture)
502 }
503 return texture
504}
505
506func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture {
507 if len(data) == 0 {
508 return nil
509 }
510 log := logging.FromContext(ctx)
511 log.Debug().
512 Int("bytes", len(data)).
513 Msg("favicon: textureFromBytesOnGTK begin")
514
515 glibCtx := glib.MainContextDefault()
516 if glibCtx != nil && glibCtx.IsOwner() {
517 texture := bytesToTexture(data)
518 log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
519 return texture
520 }
521
522 resultCh := make(chan textureResult, 1)
523 cb := glib.SourceOnceFunc(func(_ uintptr) {
524 log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK")
525 resultCh <- textureResult{texture: bytesToTexture(data)}
526 })
527 glib.IdleAddOnce(&cb, 0)
528 select {
529 case result := <-resultCh:
530 log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
531 return result.texture
532 case <-time.After(5 * time.Second):
533 log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock")
534 return nil
535 }
536}
537
538// bytesToTexture converts raw favicon bytes to a GDK texture.
539func bytesToTexture(data []byte) *gdk.Texture {
540 if len(data) == 0 {
541 return nil
542 }
543
544 bytes := glib.NewBytes(data, uint(len(data)))
545 if bytes == nil {
546 return nil
547 }
548
549 texture, err := gdk.NewTextureFromBytes(bytes)
550 if err != nil {
551 return nil
552 }
553
554 return texture
555}