v0.29.0 21b3150

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	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}

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