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