main 5041353

favicon.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close