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 "github.com/bnema/dumber/internal/domain/favicon"
12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk/v4/gdk"
15 "github.com/bnema/puregotk/v4/glib"
16 "github.com/rs/zerolog"
17)
18
19// FaviconAdapter bridges the domain FaviconService to GTK by providing
20// gdk.Texture conversion and WebKit FaviconDatabase integration.
21type FaviconAdapter struct {
22 service port.FaviconService
23 resolver port.FaviconResolver
24 faviconDB port.FaviconDatabase
25 textureCache map[string]*gdk.Texture
26 mu sync.RWMutex
27 warnMu sync.Mutex
28 warnCounts map[string]int
29
30 // App-logo / internal-URL helpers (injected to decouple from infrastructure/favicon)
31 isInternalURL func(url string) bool
32 internalDomain string
33 normalizedIconSize int
34 getLogoBytes func() []byte
35 disabled bool
36}
37
38const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS"
39
40type warningLogFunc func(log *zerolog.Logger, err error)
41
42type textureResult struct {
43 texture *gdk.Texture
44}
45
46// FaviconAdapterConfig holds optional configuration for FaviconAdapter.
47// Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
48type FaviconAdapterConfig struct {
49 // IsInternalURL reports whether a URL uses the internal app scheme (e.g. dumb://).
50 IsInternalURL func(url string) bool
51 // InternalDomain is the pseudo-domain used to cache the app logo favicon.
52 InternalDomain string
53 // NormalizedIconSize is the target size for dmenu/fuzzel icons (0 = skip).
54 NormalizedIconSize int
55 // GetLogoBytes returns the raw bytes of the app logo (nil = skip logo favicon).
56 GetLogoBytes func() []byte
57}
58
59// NewFaviconAdapter creates a new FaviconAdapter.
60// The faviconDB can be nil if a favicon database is not available.
61func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
62 return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg)
63}
64
65func NewFaviconAdapterWithResolver(
66 service port.FaviconService,
67 resolver port.FaviconResolver,
68 faviconDB port.FaviconDatabase,
69 cfg FaviconAdapterConfig,
70) *FaviconAdapter {
71 disabled := os.Getenv(disableFaviconsEnv) == "1"
72 return &FaviconAdapter{
73 service: service,
74 resolver: resolver,
75 faviconDB: faviconDB,
76 textureCache: make(map[string]*gdk.Texture),
77 warnCounts: make(map[string]int),
78 isInternalURL: cfg.IsInternalURL,
79 internalDomain: cfg.InternalDomain,
80 normalizedIconSize: cfg.NormalizedIconSize,
81 getLogoBytes: cfg.GetLogoBytes,
82 disabled: disabled,
83 }
84}
85
86// GetTexture returns a cached texture for the domain, or nil if not cached.
87func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
88 if domain == "" {
89 return nil
90 }
91
92 a.mu.RLock()
93 texture := a.textureCache[domain]
94 a.mu.RUnlock()
95 return texture
96}
97
98// GetTextureByURL returns a cached texture for the most specific URL key,
99// falling back toward the domain key. For internal dumb:// URLs, returns the
100// app logo texture.
101func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
102 texture, _, _ := a.getTextureForURL(pageURL)
103 return texture
104}
105
106func (a *FaviconAdapter) getTextureForURL(pageURL string) (*gdk.Texture, favicon.Key, bool) {
107 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
108 texture := a.GetTexture(a.internalDomain)
109 return texture, favicon.Key(a.internalDomain), texture != nil
110 }
111 exactKey, _ := favicon.CanonicalKey(pageURL)
112 for _, key := range favicon.Candidates(pageURL) {
113 if texture := a.GetTexture(string(key)); texture != nil {
114 return texture, key, exactKey != "" && key == exactKey
115 }
116 }
117 return nil, "", false
118}
119
120func (a *FaviconAdapter) setTextureForURL(pageURL string, texture *gdk.Texture) {
121 key, ok := favicon.CanonicalKey(pageURL)
122 if !ok {
123 return
124 }
125 a.setTexture(string(key), texture)
126}
127
128// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
129// The callback is invoked on the GTK main thread with the best currently
130// available texture (or nil). When a fallback texture is shown first and a more
131// specific exact engine favicon is discovered asynchronously, the callback may
132// be invoked a second time with that exact update.
133// For internal dumb:// URLs, returns the app logo texture.
134func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
135 if callback == nil {
136 return
137 }
138
139 log := logging.FromContext(ctx)
140 if a.disabled {
141 log.Warn().
142 Str("env", disableFaviconsEnv).
143 Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation")
144 callback(nil)
145 return
146 }
147
148 // Handle internal dumb:// scheme URLs - use app logo
149 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
150 callback(a.getOrCreateLogoTexture(ctx))
151 return
152 }
153
154 domain := domainurl.ExtractDomain(pageURL)
155 log.Debug().
156 Str("page_url", pageURL).
157 Str("domain", domain).
158 Msg("favicon: GetOrFetch begin")
159
160 // Exact in-memory hits can short-circuit immediately. Fallback hits still
161 // consult the resolver first so persisted exact/path-specific favicons win.
162 if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact {
163 log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: exact texture cache hit")
164 go a.ensureSizedPNG(ctx, string(key))
165 callback(texture)
166 return
167 }
168
169 // Check application resolver before using any cached parent/domain fallback.
170 if texture, key := a.resolveTexture(ctx, pageURL, true); texture != nil {
171 callback(texture)
172 a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback)
173 return
174 }
175
176 if texture, key, _ := a.getTextureForURL(pageURL); texture != nil {
177 log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: fallback texture cache hit")
178 go a.ensureSizedPNG(ctx, string(key))
179 callback(texture)
180 a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback)
181 return
182 }
183
184 // Check service cache (memory + disk) and convert to texture
185 if a.service != nil {
186 if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
187 log.Debug().
188 Str("domain", domain).
189 Int("bytes", len(data)).
190 Msg("favicon: service cache hit")
191 texture := a.textureFromBytesOnGTK(ctx, data)
192 if texture != nil {
193 a.setTexture(domain, texture)
194 // Ensure sized PNG exists for CLI tools (async, idempotent)
195 go a.ensureSizedPNG(ctx, domain)
196 callback(texture)
197 a.probeExactEngineFavicon(ctx, pageURL, domain, favicon.Key(domain), callback)
198 return
199 }
200 }
201 }
202
203 // Try engine FaviconDatabase if available
204 if a.faviconDB != nil {
205 a.queryEngineFaviconDB(ctx, pageURL, domain, callback, true)
206 return
207 }
208
209 // No WebKit DB, fetch via service directly
210 log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service")
211 a.fetchViaService(ctx, domain, callback)
212}
213
214func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
215 if callback == nil {
216 return
217 }
218 log := logging.FromContext(ctx)
219 log.Debug().
220 Str("domain", domain).
221 Bool("texture_nil", texture == nil).
222 Msg("favicon: scheduling callback on GTK")
223 cb := glib.SourceFunc(func(uintptr) bool {
224 log.Debug().
225 Str("domain", domain).
226 Bool("texture_nil", texture == nil).
227 Msg("favicon: callback executing on GTK")
228 callback(texture)
229 return false
230 })
231 glib.IdleAdd(&cb, 0)
232}
233
234func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) {
235 domain := domainurl.ExtractDomain(pageURL)
236 if a.resolver == nil {
237 a.invokeCallbackOnGTK(ctx, domain, callback, nil)
238 return
239 }
240 go func() {
241 if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil {
242 logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed")
243 }
244 if callback == nil {
245 return
246 }
247 a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) {
248 a.invokeCallbackOnGTK(ctx, domain, callback, texture)
249 })
250 }()
251}
252
253func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) (*gdk.Texture, favicon.Key) {
254 if a.resolver == nil {
255 return nil, ""
256 }
257 options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
258 if scheduleRefresh {
259 options.ScheduleBackgroundRefresh = true
260 }
261 resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
262 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
263 return nil, ""
264 }
265 texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
266 if texture != nil {
267 a.setResolvedTexture(pageURL, resolved.Key, texture)
268 }
269 return texture, resolved.Key
270}
271
272func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
273 if a.service == nil {
274 a.invokeCallbackOnGTK(ctx, domain, callback, nil)
275 return
276 }
277 go func() {
278 log := logging.FromContext(ctx)
279 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
280 data, err := a.service.Get(ctx, domain)
281 if err != nil || len(data) == 0 {
282 log.Debug().
283 Err(err).
284 Str("domain", domain).
285 Int("bytes", len(data)).
286 Msg("favicon: fetchViaService empty/error result")
287 a.invokeCallbackOnGTK(ctx, domain, callback, nil)
288 return
289 }
290
291 log.Debug().
292 Str("domain", domain).
293 Int("bytes", len(data)).
294 Msg("favicon: fetchViaService fetched data")
295 texture := a.textureFromBytesOnGTK(ctx, data)
296 if texture != nil {
297 a.setTexture(domain, texture)
298 }
299 log.Debug().
300 Str("domain", domain).
301 Bool("texture_nil", texture == nil).
302 Msg("favicon: fetchViaService end")
303 a.invokeCallbackOnGTK(ctx, domain, callback, texture)
304 }()
305}
306
307func (a *FaviconAdapter) probeExactEngineFavicon(
308 ctx context.Context,
309 pageURL, domain string,
310 resolvedKey favicon.Key,
311 callback func(*gdk.Texture),
312) {
313 if a.faviconDB == nil {
314 return
315 }
316 exactKey, ok := favicon.CanonicalKey(pageURL)
317 if !ok || exactKey == resolvedKey {
318 return
319 }
320 a.queryEngineFaviconDB(ctx, pageURL, domain, callback, false)
321}
322
323func (a *FaviconAdapter) queryEngineFaviconDB(
324 ctx context.Context,
325 pageURL, domain string,
326 callback func(*gdk.Texture),
327 fetchViaServiceOnMiss bool,
328) {
329 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
330 a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex, fetchViaServiceOnMiss)
331 })
332}
333
334func (a *FaviconAdapter) handleEngineFaviconDBTexture(
335 ctx context.Context,
336 pageURL, domain string,
337 callback func(*gdk.Texture),
338 tex port.Texture,
339 fetchViaServiceOnMiss bool,
340) {
341 log := logging.FromContext(ctx)
342 log.Debug().
343 Str("domain", domain).
344 Bool("texture_nil", tex == nil).
345 Msg("favicon: engine db callback")
346 if tex == nil {
347 if fetchViaServiceOnMiss {
348 log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
349 a.fetchViaService(ctx, domain, callback)
350 }
351 return
352 }
353 // Convert port.Texture to *gdk.Texture
354 gdkTex, ok := tex.(*gdk.Texture)
355 if !ok {
356 if fetchViaServiceOnMiss {
357 a.fetchViaService(ctx, domain, callback)
358 }
359 return
360 }
361 if a.resolver != nil {
362 a.setTextureForURL(pageURL, gdkTex)
363 } else {
364 a.setTexture(domain, gdkTex)
365 }
366 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
367 a.storeTextureObservation(ctx, pageURL, gdkTex)
368}
369
370// StoreFromWebKit stores a favicon texture received from WebKit signals.
371// Also ensures the favicon is persisted to disk cache via the service.
372func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
373 if a.disabled {
374 logging.FromContext(ctx).Debug().
375 Str("page_url", pageURL).
376 Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled")
377 return
378 }
379 if texture == nil {
380 return
381 }
382
383 domain := domainurl.ExtractDomain(pageURL)
384 if domain == "" {
385 return
386 }
387
388 if a.resolver != nil {
389 a.setTextureForURL(pageURL, texture)
390 a.storeTextureObservation(ctx, pageURL, texture)
391 return
392 }
393
394 // Legacy fallback for callers not yet wired to the application resolver.
395 a.setTexture(domain, texture)
396 a.saveFaviconToDisk(ctx, domain, texture)
397}
398
399// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
400// Used when redirects occur (e.g., example.com → www.example.com).
401func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
402 ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
403) {
404 a.StoreFromWebKit(ctx, currentURL, texture)
405
406 if originURL == "" || originURL == currentURL {
407 return
408 }
409 if a.resolver != nil {
410 // In path-aware resolver mode, redirect target favicons may carry state.
411 // Keep them scoped to the current URL only; aliasing them onto the origin
412 // URL would leak exact/path-specific state across sibling paths or hosts.
413 return
414 }
415
416 originDomain := domainurl.ExtractDomain(originURL)
417 currentDomain := domainurl.ExtractDomain(currentURL)
418 if originDomain != "" && originDomain != currentDomain {
419 a.setTexture(originDomain, texture)
420 a.saveFaviconToDisk(ctx, originDomain, texture)
421 }
422}
423
424func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) {
425 if a.resolver == nil || texture == nil || pageURL == "" {
426 return
427 }
428 cb := glib.SourceFunc(func(_ uintptr) bool {
429 tmp, err := os.CreateTemp("", "dumber-favicon-*.png")
430 if err != nil {
431 logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation")
432 return false
433 }
434 path := tmp.Name()
435 _ = tmp.Close()
436 defer func() { _ = os.Remove(path) }()
437 if ok := texture.SaveToPng(path); !ok {
438 logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation")
439 return false
440 }
441 data, err := os.ReadFile(path)
442 if err != nil || len(data) == 0 {
443 logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation")
444 return false
445 }
446 go func() {
447 // Engine-provided textures do not expose the original icon URL, so pageURL
448 // is passed as the observation iconURL fallback for SourceEngine records.
449 if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil {
450 logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture")
451 }
452 }()
453 return false
454 })
455 glib.IdleAdd(&cb, 0)
456}
457
458// saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
459// Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
460func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
461 if a.service == nil {
462 return
463 }
464 log := logging.FromContext(ctx)
465 cacheDirWarnKey := "cache-dir"
466 savePNGWarnKey := "save-png:" + domain
467 sizedPNGWarnKey := "sized-png:" + domain
468
469 // Check if PNG save is needed before scheduling idle callback
470 pngPath := a.service.DiskPathPNG(domain)
471 needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain)
472
473 if needsPNGSave {
474 // Ensure cache directory exists (cheap check, do it now)
475 if err := a.service.EnsureCacheDir(); err != nil {
476 a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) {
477 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir")
478 })
479 needsPNGSave = false
480 } else {
481 a.clearWarningDedup(cacheDirWarnKey)
482 }
483 }
484
485 if needsPNGSave {
486 // Schedule PNG save for when GTK main loop is idle (non-blocking)
487 // texture.SaveToPng must run on main thread but we defer it to avoid blocking
488 cb := glib.SourceFunc(func(_ uintptr) bool {
489 if ok := texture.SaveToPng(pngPath); !ok {
490 a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) {
491 log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
492 })
493 } else {
494 a.clearWarningDedup(savePNGWarnKey)
495 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
496
497 // Create normalized sized copy for dmenu/fuzzel (async, only if size is set)
498 if a.normalizedIconSize > 0 {
499 go func() {
500 if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
501 a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
502 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
503 })
504 } else {
505 a.clearWarningDedup(sizedPNGWarnKey)
506 log.Debug().Str("domain", domain).Msg("created sized PNG")
507 }
508 }()
509 }
510 }
511 return false // don't repeat
512 })
513 glib.IdleAdd(&cb, 0)
514 }
515
516 // Ensure disk cache is populated (async, in background)
517 go a.service.EnsureDiskCache(ctx, domain)
518}
519
520// PreloadFromCache attempts to load a favicon from cache without fetching.
521// Returns the texture if found in memory or disk cache, nil otherwise.
522func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture {
523 if a.disabled {
524 logging.FromContext(ctx).Debug().
525 Str("page_url", pageURL).
526 Str("env", disableFaviconsEnv).
527 Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
528 return nil
529 }
530 domain := domainurl.ExtractDomain(pageURL)
531 if domain == "" {
532 return nil
533 }
534 log := logging.FromContext(ctx)
535 log.Debug().
536 Str("page_url", pageURL).
537 Str("domain", domain).
538 Msg("favicon: PreloadFromCache begin")
539
540 if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact {
541 log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache exact texture hit")
542 return texture
543 }
544
545 // Check application resolver before falling back to cached parent/domain entries.
546 if texture, _ := a.resolveTexture(ctx, pageURL, false); texture != nil {
547 return texture
548 }
549
550 if texture, key, _ := a.getTextureForURL(pageURL); texture != nil {
551 log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache fallback texture hit")
552 return texture
553 }
554
555 // Check service cache (memory + disk)
556 if a.service != nil {
557 if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
558 log.Debug().
559 Str("domain", domain).
560 Int("bytes", len(data)).
561 Msg("favicon: PreloadFromCache service hit")
562 texture := a.textureFromBytesOnGTK(ctx, data)
563 if texture != nil {
564 a.setTexture(domain, texture)
565 return texture
566 }
567 }
568 }
569
570 log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss")
571 return nil
572}
573
574// Service returns the underlying favicon service.
575// Used by CLI components that need disk paths.
576func (a *FaviconAdapter) Service() port.FaviconService {
577 return a.service
578}
579
580// Close shuts down the adapter and underlying service.
581func (a *FaviconAdapter) Close() {
582 if a.service != nil {
583 a.service.Close()
584 }
585}
586
587// Invalidate clears one resolved-key texture cache entry.
588func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
589 a.mu.Lock()
590 delete(a.textureCache, string(key))
591 a.mu.Unlock()
592 return nil
593}
594
595// Clear removes all entries from the texture cache.
596func (a *FaviconAdapter) Clear() {
597 a.mu.Lock()
598 a.textureCache = make(map[string]*gdk.Texture)
599 a.mu.Unlock()
600}
601
602// Size returns the number of entries in the texture cache.
603func (a *FaviconAdapter) Size() int {
604 a.mu.RLock()
605 size := len(a.textureCache)
606 a.mu.RUnlock()
607 return size
608}
609
610// setResolvedTexture stores resolver results only under the resolved favicon
611// key. Exact/parent/domain fallback ordering is handled by favicon.Candidates
612// during lookup, so synthetic aliases would only risk masking more specific
613// entries that arrive later.
614func (a *FaviconAdapter) setResolvedTexture(_ string, key favicon.Key, texture *gdk.Texture) {
615 if key == "" || texture == nil {
616 return
617 }
618 a.mu.Lock()
619 a.textureCache[string(key)] = texture
620 a.mu.Unlock()
621}
622
623// setTexture stores a texture in the cache.
624func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
625 if domain == "" || texture == nil {
626 return
627 }
628 a.mu.Lock()
629 a.textureCache[domain] = texture
630 a.mu.Unlock()
631}
632
633// ensureSizedPNG ensures the sized PNG exists for CLI tools.
634// This is idempotent - it only creates the file if it doesn't exist.
635func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
636 if domain == "" || a.service == nil {
637 return
638 }
639
640 log := logging.FromContext(ctx)
641
642 hasPNG := a.service.HasPNGOnDisk(domain)
643 hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize)
644
645 log.Debug().
646 Str("domain", domain).
647 Bool("has_png", hasPNG).
648 Bool("has_sized", hasSized).
649 Msg("checking sized PNG status")
650
651 // Only create if original PNG exists but sized version doesn't
652 if hasPNG && !hasSized {
653 log.Debug().Str("domain", domain).Msg("creating sized PNG")
654 if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
655 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
656 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
657 })
658 } else {
659 a.clearWarningDedup("sized-png:" + domain)
660 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
661 }
662 }
663}
664
665func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) {
666 a.warnMu.Lock()
667 defer a.warnMu.Unlock()
668
669 count := a.warnCounts[key] + 1
670 a.warnCounts[key] = count
671
672 return count == 1, count - 1
673}
674
675func (a *FaviconAdapter) clearWarningDedup(key string) {
676 a.warnMu.Lock()
677 delete(a.warnCounts, key)
678 a.warnMu.Unlock()
679}
680
681func (a *FaviconAdapter) logWarningDedup(
682 ctx context.Context,
683 key string,
684 err error,
685 warnFn warningLogFunc,
686) {
687 log := logging.FromContext(ctx)
688 logWarning, suppressed := a.shouldLogWarningDedup(key)
689 if logWarning {
690 warnFn(log, err)
691 return
692 }
693
694 if suppressed == 1 {
695 event := log.Debug()
696 if err != nil {
697 event = event.Err(err)
698 }
699 event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning")
700 }
701}
702
703// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
704// The texture is cached under the internalDomain key.
705func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture {
706 if a.internalDomain == "" || a.getLogoBytes == nil {
707 return nil
708 }
709
710 // Check cache first
711 if texture := a.GetTexture(a.internalDomain); texture != nil {
712 return texture
713 }
714
715 // Create texture from logo bytes
716 logoBytes := a.getLogoBytes()
717 texture := a.textureFromBytesOnGTK(ctx, logoBytes)
718 if texture != nil {
719 a.setTexture(a.internalDomain, texture)
720 }
721 return texture
722}
723
724func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture {
725 if len(data) == 0 {
726 return nil
727 }
728 log := logging.FromContext(ctx)
729 log.Debug().
730 Int("bytes", len(data)).
731 Msg("favicon: textureFromBytesOnGTK begin")
732
733 glibCtx := glib.MainContextDefault()
734 if glibCtx != nil && glibCtx.IsOwner() {
735 texture := bytesToTexture(data)
736 log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
737 return texture
738 }
739
740 resultCh := make(chan textureResult, 1)
741 cb := glib.SourceOnceFunc(func(_ uintptr) {
742 log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK")
743 resultCh <- textureResult{texture: bytesToTexture(data)}
744 })
745 glib.IdleAddOnce(&cb, 0)
746 select {
747 case result := <-resultCh:
748 log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
749 return result.texture
750 case <-time.After(5 * time.Second):
751 log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock")
752 return nil
753 }
754}
755
756// bytesToTexture converts raw favicon bytes to a GDK texture.
757func bytesToTexture(data []byte) *gdk.Texture {
758 if len(data) == 0 {
759 return nil
760 }
761
762 bytes := glib.NewBytes(data, uint(len(data)))
763 if bytes == nil {
764 return nil
765 }
766
767 texture, err := gdk.NewTextureFromBytes(bytes)
768 if err != nil {
769 return nil
770 }
771
772 return texture
773}