// Package adapter provides UI-layer adapters that bridge domain services to GTK. package adapter import ( "context" "os" "sync" "time" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/domain/favicon" domainurl "github.com/bnema/dumber/internal/domain/url" "github.com/bnema/dumber/internal/logging" "github.com/bnema/puregotk/v4/gdk" "github.com/bnema/puregotk/v4/glib" "github.com/rs/zerolog" ) // FaviconAdapter bridges the domain FaviconService to GTK by providing // gdk.Texture conversion and WebKit FaviconDatabase integration. type FaviconAdapter struct { service port.FaviconService resolver port.FaviconResolver faviconDB port.FaviconDatabase textureCache map[string]*gdk.Texture mu sync.RWMutex warnMu sync.Mutex warnCounts map[string]int // App-logo / internal-URL helpers (injected to decouple from infrastructure/favicon) isInternalURL func(url string) bool internalDomain string normalizedIconSize int getLogoBytes func() []byte disabled bool } const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS" type warningLogFunc func(log *zerolog.Logger, err error) type textureResult struct { texture *gdk.Texture } // FaviconAdapterConfig holds optional configuration for FaviconAdapter. // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation). type FaviconAdapterConfig struct { // IsInternalURL reports whether a URL uses the internal app scheme (e.g. dumb://). IsInternalURL func(url string) bool // InternalDomain is the pseudo-domain used to cache the app logo favicon. InternalDomain string // NormalizedIconSize is the target size for dmenu/fuzzel icons (0 = skip). NormalizedIconSize int // GetLogoBytes returns the raw bytes of the app logo (nil = skip logo favicon). GetLogoBytes func() []byte } // NewFaviconAdapter creates a new FaviconAdapter. // The faviconDB can be nil if a favicon database is not available. func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter { return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg) } func NewFaviconAdapterWithResolver( service port.FaviconService, resolver port.FaviconResolver, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig, ) *FaviconAdapter { disabled := os.Getenv(disableFaviconsEnv) == "1" return &FaviconAdapter{ service: service, resolver: resolver, faviconDB: faviconDB, textureCache: make(map[string]*gdk.Texture), warnCounts: make(map[string]int), isInternalURL: cfg.IsInternalURL, internalDomain: cfg.InternalDomain, normalizedIconSize: cfg.NormalizedIconSize, getLogoBytes: cfg.GetLogoBytes, disabled: disabled, } } // GetTexture returns a cached texture for the domain, or nil if not cached. func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture { if domain == "" { return nil } a.mu.RLock() texture := a.textureCache[domain] a.mu.RUnlock() return texture } // GetTextureByURL returns a cached texture for the most specific URL key, // falling back toward the domain key. For internal dumb:// URLs, returns the // app logo texture. func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture { texture, _, _ := a.getTextureForURL(pageURL) return texture } func (a *FaviconAdapter) getTextureForURL(pageURL string) (*gdk.Texture, favicon.Key, bool) { if a.isInternalURL != nil && a.isInternalURL(pageURL) { texture := a.GetTexture(a.internalDomain) return texture, favicon.Key(a.internalDomain), texture != nil } exactKey, _ := favicon.CanonicalKey(pageURL) for _, key := range favicon.Candidates(pageURL) { if texture := a.GetTexture(string(key)); texture != nil { return texture, key, exactKey != "" && key == exactKey } } return nil, "", false } func (a *FaviconAdapter) setTextureForURL(pageURL string, texture *gdk.Texture) { key, ok := favicon.CanonicalKey(pageURL) if !ok { return } a.setTexture(string(key), texture) } // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed. // The callback is invoked on the GTK main thread with the best currently // available texture (or nil). When a fallback texture is shown first and a more // specific exact engine favicon is discovered asynchronously, the callback may // be invoked a second time with that exact update. // For internal dumb:// URLs, returns the app logo texture. func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) { if callback == nil { return } log := logging.FromContext(ctx) if a.disabled { log.Warn(). Str("env", disableFaviconsEnv). Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation") callback(nil) return } // Handle internal dumb:// scheme URLs - use app logo if a.isInternalURL != nil && a.isInternalURL(pageURL) { callback(a.getOrCreateLogoTexture(ctx)) return } domain := domainurl.ExtractDomain(pageURL) log.Debug(). Str("page_url", pageURL). Str("domain", domain). Msg("favicon: GetOrFetch begin") // Exact in-memory hits can short-circuit immediately. Fallback hits still // consult the resolver first so persisted exact/path-specific favicons win. if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact { log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: exact texture cache hit") go a.ensureSizedPNG(ctx, string(key)) callback(texture) return } // Check application resolver before using any cached parent/domain fallback. if texture, key := a.resolveTexture(ctx, pageURL, true); texture != nil { callback(texture) a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback) return } if texture, key, _ := a.getTextureForURL(pageURL); texture != nil { log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: fallback texture cache hit") go a.ensureSizedPNG(ctx, string(key)) callback(texture) a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback) return } // Check service cache (memory + disk) and convert to texture if a.service != nil { if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { log.Debug(). Str("domain", domain). Int("bytes", len(data)). Msg("favicon: service cache hit") texture := a.textureFromBytesOnGTK(ctx, data) if texture != nil { a.setTexture(domain, texture) // Ensure sized PNG exists for CLI tools (async, idempotent) go a.ensureSizedPNG(ctx, domain) callback(texture) a.probeExactEngineFavicon(ctx, pageURL, domain, favicon.Key(domain), callback) return } } } // Try engine FaviconDatabase if available if a.faviconDB != nil { a.queryEngineFaviconDB(ctx, pageURL, domain, callback, true) return } // No WebKit DB, fetch via service directly log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") a.fetchViaService(ctx, domain, callback) } func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) { if callback == nil { return } log := logging.FromContext(ctx) log.Debug(). Str("domain", domain). Bool("texture_nil", texture == nil). Msg("favicon: scheduling callback on GTK") cb := glib.SourceFunc(func(uintptr) bool { log.Debug(). Str("domain", domain). Bool("texture_nil", texture == nil). Msg("favicon: callback executing on GTK") callback(texture) return false }) glib.IdleAdd(&cb, 0) } func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) { domain := domainurl.ExtractDomain(pageURL) if a.resolver == nil { a.invokeCallbackOnGTK(ctx, domain, callback, nil) return } go func() { if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil { logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed") } if callback == nil { return } a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) { a.invokeCallbackOnGTK(ctx, domain, callback, texture) }) }() } func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) (*gdk.Texture, favicon.Key) { if a.resolver == nil { return nil, "" } options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI} if scheduleRefresh { options.ScheduleBackgroundRefresh = true } resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options) if err != nil || resolved == nil || len(resolved.Bytes) == 0 { return nil, "" } texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes) if texture != nil { a.setResolvedTexture(pageURL, resolved.Key, texture) } return texture, resolved.Key } func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) { if a.service == nil { a.invokeCallbackOnGTK(ctx, domain, callback, nil) return } go func() { log := logging.FromContext(ctx) log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") data, err := a.service.Get(ctx, domain) if err != nil || len(data) == 0 { log.Debug(). Err(err). Str("domain", domain). Int("bytes", len(data)). Msg("favicon: fetchViaService empty/error result") a.invokeCallbackOnGTK(ctx, domain, callback, nil) return } log.Debug(). Str("domain", domain). Int("bytes", len(data)). Msg("favicon: fetchViaService fetched data") texture := a.textureFromBytesOnGTK(ctx, data) if texture != nil { a.setTexture(domain, texture) } log.Debug(). Str("domain", domain). Bool("texture_nil", texture == nil). Msg("favicon: fetchViaService end") a.invokeCallbackOnGTK(ctx, domain, callback, texture) }() } func (a *FaviconAdapter) probeExactEngineFavicon( ctx context.Context, pageURL, domain string, resolvedKey favicon.Key, callback func(*gdk.Texture), ) { if a.faviconDB == nil { return } exactKey, ok := favicon.CanonicalKey(pageURL) if !ok || exactKey == resolvedKey { return } a.queryEngineFaviconDB(ctx, pageURL, domain, callback, false) } func (a *FaviconAdapter) queryEngineFaviconDB( ctx context.Context, pageURL, domain string, callback func(*gdk.Texture), fetchViaServiceOnMiss bool, ) { a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex, fetchViaServiceOnMiss) }) } func (a *FaviconAdapter) handleEngineFaviconDBTexture( ctx context.Context, pageURL, domain string, callback func(*gdk.Texture), tex port.Texture, fetchViaServiceOnMiss bool, ) { log := logging.FromContext(ctx) log.Debug(). Str("domain", domain). Bool("texture_nil", tex == nil). Msg("favicon: engine db callback") if tex == nil { if fetchViaServiceOnMiss { log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") a.fetchViaService(ctx, domain, callback) } return } // Convert port.Texture to *gdk.Texture gdkTex, ok := tex.(*gdk.Texture) if !ok { if fetchViaServiceOnMiss { a.fetchViaService(ctx, domain, callback) } return } if a.resolver != nil { a.setTextureForURL(pageURL, gdkTex) } else { a.setTexture(domain, gdkTex) } a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex) a.storeTextureObservation(ctx, pageURL, gdkTex) } // StoreFromWebKit stores a favicon texture received from WebKit signals. // Also ensures the favicon is persisted to disk cache via the service. func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) { if a.disabled { logging.FromContext(ctx).Debug(). Str("page_url", pageURL). Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled") return } if texture == nil { return } domain := domainurl.ExtractDomain(pageURL) if domain == "" { return } if a.resolver != nil { a.setTextureForURL(pageURL, texture) a.storeTextureObservation(ctx, pageURL, texture) return } // Legacy fallback for callers not yet wired to the application resolver. a.setTexture(domain, texture) a.saveFaviconToDisk(ctx, domain, texture) } // StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL. // Used when redirects occur (e.g., example.com → www.example.com). func (a *FaviconAdapter) StoreFromWebKitWithOrigin( ctx context.Context, currentURL, originURL string, texture *gdk.Texture, ) { a.StoreFromWebKit(ctx, currentURL, texture) if originURL == "" || originURL == currentURL { return } if a.resolver != nil { // In path-aware resolver mode, redirect target favicons may carry state. // Keep them scoped to the current URL only; aliasing them onto the origin // URL would leak exact/path-specific state across sibling paths or hosts. return } originDomain := domainurl.ExtractDomain(originURL) currentDomain := domainurl.ExtractDomain(currentURL) if originDomain != "" && originDomain != currentDomain { a.setTexture(originDomain, texture) a.saveFaviconToDisk(ctx, originDomain, texture) } } func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) { if a.resolver == nil || texture == nil || pageURL == "" { return } cb := glib.SourceFunc(func(_ uintptr) bool { tmp, err := os.CreateTemp("", "dumber-favicon-*.png") if err != nil { logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation") return false } path := tmp.Name() _ = tmp.Close() defer func() { _ = os.Remove(path) }() if ok := texture.SaveToPng(path); !ok { logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation") return false } data, err := os.ReadFile(path) if err != nil || len(data) == 0 { logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation") return false } go func() { // Engine-provided textures do not expose the original icon URL, so pageURL // is passed as the observation iconURL fallback for SourceEngine records. if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil { logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture") } }() return false }) glib.IdleAdd(&cb, 0) } // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated. // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking. func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) { if a.service == nil { return } log := logging.FromContext(ctx) cacheDirWarnKey := "cache-dir" savePNGWarnKey := "save-png:" + domain sizedPNGWarnKey := "sized-png:" + domain // Check if PNG save is needed before scheduling idle callback pngPath := a.service.DiskPathPNG(domain) needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain) if needsPNGSave { // Ensure cache directory exists (cheap check, do it now) if err := a.service.EnsureCacheDir(); err != nil { a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) { log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir") }) needsPNGSave = false } else { a.clearWarningDedup(cacheDirWarnKey) } } if needsPNGSave { // Schedule PNG save for when GTK main loop is idle (non-blocking) // texture.SaveToPng must run on main thread but we defer it to avoid blocking cb := glib.SourceFunc(func(_ uintptr) bool { if ok := texture.SaveToPng(pngPath); !ok { a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) { log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG") }) } else { a.clearWarningDedup(savePNGWarnKey) log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG") // Create normalized sized copy for dmenu/fuzzel (async, only if size is set) if a.normalizedIconSize > 0 { go func() { if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil { a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) { log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG") }) } else { a.clearWarningDedup(sizedPNGWarnKey) log.Debug().Str("domain", domain).Msg("created sized PNG") } }() } } return false // don't repeat }) glib.IdleAdd(&cb, 0) } // Ensure disk cache is populated (async, in background) go a.service.EnsureDiskCache(ctx, domain) } // PreloadFromCache attempts to load a favicon from cache without fetching. // Returns the texture if found in memory or disk cache, nil otherwise. func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture { if a.disabled { logging.FromContext(ctx).Debug(). Str("page_url", pageURL). Str("env", disableFaviconsEnv). Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled") return nil } domain := domainurl.ExtractDomain(pageURL) if domain == "" { return nil } log := logging.FromContext(ctx) log.Debug(). Str("page_url", pageURL). Str("domain", domain). Msg("favicon: PreloadFromCache begin") if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact { log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache exact texture hit") return texture } // Check application resolver before falling back to cached parent/domain entries. if texture, _ := a.resolveTexture(ctx, pageURL, false); texture != nil { return texture } if texture, key, _ := a.getTextureForURL(pageURL); texture != nil { log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache fallback texture hit") return texture } // Check service cache (memory + disk) if a.service != nil { if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { log.Debug(). Str("domain", domain). Int("bytes", len(data)). Msg("favicon: PreloadFromCache service hit") texture := a.textureFromBytesOnGTK(ctx, data) if texture != nil { a.setTexture(domain, texture) return texture } } } log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss") return nil } // Service returns the underlying favicon service. // Used by CLI components that need disk paths. func (a *FaviconAdapter) Service() port.FaviconService { return a.service } // Close shuts down the adapter and underlying service. func (a *FaviconAdapter) Close() { if a.service != nil { a.service.Close() } } // Invalidate clears one resolved-key texture cache entry. func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error { a.mu.Lock() delete(a.textureCache, string(key)) a.mu.Unlock() return nil } // Clear removes all entries from the texture cache. func (a *FaviconAdapter) Clear() { a.mu.Lock() a.textureCache = make(map[string]*gdk.Texture) a.mu.Unlock() } // Size returns the number of entries in the texture cache. func (a *FaviconAdapter) Size() int { a.mu.RLock() size := len(a.textureCache) a.mu.RUnlock() return size } // setResolvedTexture stores resolver results only under the resolved favicon // key. Exact/parent/domain fallback ordering is handled by favicon.Candidates // during lookup, so synthetic aliases would only risk masking more specific // entries that arrive later. func (a *FaviconAdapter) setResolvedTexture(_ string, key favicon.Key, texture *gdk.Texture) { if key == "" || texture == nil { return } a.mu.Lock() a.textureCache[string(key)] = texture a.mu.Unlock() } // setTexture stores a texture in the cache. func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) { if domain == "" || texture == nil { return } a.mu.Lock() a.textureCache[domain] = texture a.mu.Unlock() } // ensureSizedPNG ensures the sized PNG exists for CLI tools. // This is idempotent - it only creates the file if it doesn't exist. func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) { if domain == "" || a.service == nil { return } log := logging.FromContext(ctx) hasPNG := a.service.HasPNGOnDisk(domain) hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize) log.Debug(). Str("domain", domain). Bool("has_png", hasPNG). Bool("has_sized", hasSized). Msg("checking sized PNG status") // Only create if original PNG exists but sized version doesn't if hasPNG && !hasSized { log.Debug().Str("domain", domain).Msg("creating sized PNG") if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil { a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) { log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG") }) } else { a.clearWarningDedup("sized-png:" + domain) log.Debug().Str("domain", domain).Msg("sized PNG created successfully") } } } func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) { a.warnMu.Lock() defer a.warnMu.Unlock() count := a.warnCounts[key] + 1 a.warnCounts[key] = count return count == 1, count - 1 } func (a *FaviconAdapter) clearWarningDedup(key string) { a.warnMu.Lock() delete(a.warnCounts, key) a.warnMu.Unlock() } func (a *FaviconAdapter) logWarningDedup( ctx context.Context, key string, err error, warnFn warningLogFunc, ) { log := logging.FromContext(ctx) logWarning, suppressed := a.shouldLogWarningDedup(key) if logWarning { warnFn(log, err) return } if suppressed == 1 { event := log.Debug() if err != nil { event = event.Err(err) } event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning") } } // getOrCreateLogoTexture returns the app logo texture, creating it if needed. // The texture is cached under the internalDomain key. func (a *FaviconAdapter) getOrCreateLogoTexture(ctx context.Context) *gdk.Texture { if a.internalDomain == "" || a.getLogoBytes == nil { return nil } // Check cache first if texture := a.GetTexture(a.internalDomain); texture != nil { return texture } // Create texture from logo bytes logoBytes := a.getLogoBytes() texture := a.textureFromBytesOnGTK(ctx, logoBytes) if texture != nil { a.setTexture(a.internalDomain, texture) } return texture } func (a *FaviconAdapter) textureFromBytesOnGTK(ctx context.Context, data []byte) *gdk.Texture { if len(data) == 0 { return nil } log := logging.FromContext(ctx) log.Debug(). Int("bytes", len(data)). Msg("favicon: textureFromBytesOnGTK begin") glibCtx := glib.MainContextDefault() if glibCtx != nil && glibCtx.IsOwner() { texture := bytesToTexture(data) log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline") return texture } resultCh := make(chan textureResult, 1) cb := glib.SourceOnceFunc(func(_ uintptr) { log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK") resultCh <- textureResult{texture: bytesToTexture(data)} }) glib.IdleAddOnce(&cb, 0) select { case result := <-resultCh: log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") return result.texture case <-time.After(5 * time.Second): log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock") return nil } } // bytesToTexture converts raw favicon bytes to a GDK texture. func bytesToTexture(data []byte) *gdk.Texture { if len(data) == 0 { return nil } bytes := glib.NewBytes(data, uint(len(data))) if bytes == nil { return nil } texture, err := gdk.NewTextureFromBytes(bytes) if err != nil { return nil } return texture }