v0.15.0 9006957

favicon_service.go RawHistoryFolder

1// Package services contains application services that orchestrate business logic.
2package services
3
4import (
5	"context"
6	"database/sql"
7	"errors"
8	"fmt"
9	"os"
10	"path/filepath"
11	"sync"
12	"time"
13
14	"github.com/bnema/dumber/internal/db"
15	"github.com/bnema/dumber/internal/logging"
16	"github.com/bnema/dumber/internal/parser"
17	"github.com/bnema/dumber/pkg/webkit"
18	"github.com/diamondburned/gotk4/pkg/gdk/v4"
19	"github.com/diamondburned/gotk4/pkg/gio/v2"
20)
21
22// FaviconService provides unified favicon access using WebKitGTK's native database.
23// The service keeps WebKit's FaviconDatabase as the source of truth and augments it
24// with fallback rasterisation for SVG favicons so that callers always receive a
25// 32x32 texture or an explicit error.
26type FaviconService struct {
27	faviconDB    *webkit.FaviconDatabase
28	dbQueries    db.DatabaseQuerier
29	targetSize   int    // Target size for exported favicons (32x32 for high quality)
30	exportDir    string // Directory for exporting favicons for CLI access
31	dataDir      string // WebKit data directory
32	enableExport bool   // Whether to export favicons for CLI tools
33
34	// Deduplication: tracks recently processed (pageURL, faviconURI) pairs
35	// Key: pageURL, Value: struct{faviconURI string, timestamp time.Time}
36	recentChanges sync.Map
37
38	// Debouncing: tracks ongoing preload operations to prevent duplicates
39	// Key: pageURL, Value: bool (true if preload is in progress)
40	ongoingPreloads sync.Map
41}
42
43const (
44	// DefaultFaviconSize is the target size for exported favicons (32x32 for high quality)
45	DefaultFaviconSize = 32
46	// FaviconTimeout is the maximum time to wait for a favicon to be available
47	FaviconTimeout = 5 * time.Second
48	// DeduplicationWindow is the time window for ignoring duplicate favicon changes
49	DeduplicationWindow = 2 * time.Second
50)
51
52// ServiceName returns the service name for identification
53func (fs *FaviconService) ServiceName() string {
54	return "FaviconService"
55}
56
57// NewFaviconService creates a new favicon service
58func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQuerier, dataDir string) (*FaviconService, error) {
59	if faviconDB == nil {
60		return nil, fmt.Errorf("favicon database cannot be nil")
61	}
62	if queries == nil {
63		return nil, fmt.Errorf("database queries cannot be nil")
64	}
65
66	// Create export directory for CLI access
67	exportDir := filepath.Join(dataDir, "favicons-export")
68	if err := os.MkdirAll(exportDir, 0750); err != nil {
69		return nil, fmt.Errorf("failed to create favicon export directory: %w", err)
70	}
71
72	fs := &FaviconService{
73		faviconDB:    faviconDB,
74		dbQueries:    queries,
75		targetSize:   DefaultFaviconSize,
76		exportDir:    exportDir,
77		dataDir:      dataDir,
78		enableExport: true,
79	}
80
81	// Register the favicon changed handler ONCE at the service level
82	// This is critical to avoid duplicate handlers when multiple WebViews exist
83	// The FaviconDatabase is shared across all WebViews (at NetworkSession level)
84	faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
85		if err := fs.OnFaviconChanged(pageURI, faviconURI); err != nil {
86			logging.Error(fmt.Sprintf("[favicon] Handler error for %s: %v", pageURI, err))
87		}
88	})
89
90	logging.Info(fmt.Sprintf("[favicon] Registered single shared favicon handler at FaviconService level"))
91
92	return fs, nil
93}
94
95// OnFaviconChanged handles favicon URI changes from WebKit's favicon database.
96// We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture.
97func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error {
98	if !fs.shouldProcessURL(pageURL) {
99		return nil
100	}
101
102	canonicalURL := parser.NormalizeHistoryURL(pageURL)
103	if canonicalURL == "" {
104		canonicalURL = pageURL
105	}
106
107	// FIRST: Check if WebKit's FaviconDatabase already has this exact URI
108	// WebKit's database persists across sessions and handles caching internally
109	// If it's already stored, this is just a notification on refresh/reload - skip it
110	existingURI := fs.faviconDB.FaviconURI(pageURL)
111	if existingURI == faviconURI {
112		// WebKit already has this favicon cached - nothing to do
113		// This is the common case on refresh/new window
114		return nil
115	}
116
117	// Deduplication for rapid-fire events on initial page load
118	// (e.g., GitHub fires PNG then SVG within milliseconds)
119	// Keep the first one we see and ignore subsequent variants
120	type recentChange struct {
121		faviconURI string
122		timestamp  time.Time
123	}
124
125	if cached, ok := fs.recentChanges.Load(canonicalURL); ok {
126		if rc, ok := cached.(recentChange); ok {
127			if time.Since(rc.timestamp) < DeduplicationWindow {
128				// Already processed a favicon for this URL recently
129				// This prevents flip-flopping between PNG/SVG/ICO variants
130				return nil
131			}
132		}
133	}
134
135	// NEW favicon detected (first time or actual change)
136	logging.Info(fmt.Sprintf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI))
137
138	ctx := context.Background()
139
140	// Update our history database with the favicon URI
141	nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""}
142	if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, canonicalURL); err != nil {
143		logging.Error(fmt.Sprintf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err))
144		return fmt.Errorf("failed to update favicon in database: %w", err)
145	}
146
147	// Store this change as recently processed
148	fs.recentChanges.Store(canonicalURL, recentChange{faviconURI: faviconURI, timestamp: time.Now()})
149
150	// Propagate favicon to domain asynchronously to avoid blocking WebKit
151	go fs.propagateFaviconToDomain(canonicalURL, faviconURI)
152
153	// Proactively render and export favicon for omnibox/CLI use (debounced)
154	if fs.enableExport {
155		// Check if a preload is already in progress for this URL
156		if _, loaded := fs.ongoingPreloads.LoadOrStore(canonicalURL, true); !loaded {
157			// No preload in progress, start one
158			go func() {
159				defer fs.ongoingPreloads.Delete(canonicalURL)
160				fs.preloadFavicon(pageURL, canonicalURL, faviconURI)
161			}()
162		}
163	}
164
165	return nil
166}
167
168// propagateFaviconToDomain updates all history entries from the same domain
169// with the favicon. This ensures multiple paths on the same domain share the favicon
170// even if WebKit only signals it once per domain (KISS + DRY principle).
171func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) {
172	ctx := context.Background()
173
174	parsedURL, err := webkit.ParseURL(pageURL)
175	if err != nil {
176		return // Invalid URL, skip propagation
177	}
178
179	faviconNull := sql.NullString{String: faviconURI, Valid: true}
180
181	// Search for exact domain match first
182	domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true}
183	entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000)
184	if err != nil {
185		logging.Error(fmt.Sprintf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err))
186		return
187	}
188
189	// Also search for www variant to handle www/non-www redirects
190	// google.com should share favicon with www.google.com and vice versa
191	var altPattern sql.NullString
192	if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." {
193		// Current is www.google.com, also search google.com
194		hostWithoutWWW := parsedURL.Host[4:]
195		altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true}
196	} else {
197		// Current is google.com, also search www.google.com
198		altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true}
199	}
200
201	altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000)
202	entries = append(entries, altEntries...)
203
204	// Update all entries that don't already have a favicon
205	for _, entry := range entries {
206		if entry.FaviconUrl.String == "" {
207			if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil {
208				logging.Error(fmt.Sprintf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err))
209			} else {
210				logging.Debug(fmt.Sprintf("[favicon] Propagated favicon to %s", entry.Url))
211			}
212		}
213	}
214}
215
216// preloadFavicon renders and exports a favicon in the background.
217// This is called immediately when OnFaviconChanged fires to ensure
218// favicons are ready for omnibox suggestions as soon as possible.
219func (fs *FaviconService) preloadFavicon(pageURL, canonicalURL, faviconURI string) {
220	ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
221	defer cancel()
222
223	texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
224	if err != nil {
225		logging.Warn(fmt.Sprintf("[favicon] Preload failed for %s: %v", pageURL, err))
226		return
227	}
228
229	fs.maybeExportTexture(canonicalURL, texture)
230	logging.Debug(fmt.Sprintf("[favicon] Preloaded and exported favicon for %s", pageURL))
231}
232
233// GetFaviconTexture loads a favicon texture asynchronously and calls the callback with the result.
234// The callback receives a 32x32 GdkTexture suitable for display in GTK widgets, or an error.
235func (fs *FaviconService) GetFaviconTexture(pageURL string, callback func(*gdk.Texture, error)) {
236	if callback == nil {
237		return
238	}
239	if pageURL == "" {
240		fs.dispatchTexture(callback, nil, fmt.Errorf("page URL is empty"))
241		return
242	}
243
244	go func() {
245		ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
246		defer cancel()
247
248		canonicalURL := parser.NormalizeHistoryURL(pageURL)
249		if canonicalURL == "" {
250			canonicalURL = pageURL
251		}
252
253		faviconURI, err := fs.lookupFaviconURI(ctx, canonicalURL, pageURL)
254		if err != nil {
255			fs.dispatchTexture(callback, nil, err)
256			return
257		}
258
259		texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
260		if err != nil {
261			fs.dispatchTexture(callback, nil, err)
262			return
263		}
264
265		fs.maybeExportTexture(canonicalURL, texture)
266
267		fs.dispatchTexture(callback, texture, nil)
268	}()
269}
270
271func (fs *FaviconService) renderFaviconTexture(ctx context.Context, pageURL, faviconURI string) (*gdk.Texture, error) {
272	if faviconURI == "" {
273		return nil, fmt.Errorf("no favicon URI available")
274	}
275
276	if fs.isSVGFavicon(faviconURI) {
277		return fs.loadSVGFaviconTexture(ctx, faviconURI)
278	}
279
280	if fs.faviconDB == nil {
281		return nil, fmt.Errorf("favicon database not initialized")
282	}
283
284	type result struct {
285		texture *gdk.Texture
286		err     error
287	}
288
289	resultCh := make(chan result, 1)
290
291	fs.faviconDB.Favicon(ctx, pageURL, func(res gio.AsyncResulter) {
292		if res == nil {
293			select {
294			case resultCh <- result{err: fmt.Errorf("favicon async result is nil")}:
295			default:
296			}
297			return
298		}
299
300		texture, err := webkit.FaviconFinishSafe(fs.faviconDB, res)
301		if err == nil && texture == nil {
302			err = fmt.Errorf("favicon unavailable")
303		}
304		if err == nil {
305			texture = fs.ensureTextureSize(texture)
306			if texture == nil {
307				err = fmt.Errorf("failed to scale favicon texture")
308			}
309		}
310
311		select {
312		case resultCh <- result{texture: texture, err: err}:
313		default:
314		}
315	})
316
317	select {
318	case <-ctx.Done():
319		return nil, ctx.Err()
320	case res := <-resultCh:
321		return res.texture, res.err
322	}
323}
324
325func (fs *FaviconService) loadSVGFaviconTexture(ctx context.Context, faviconURI string) (*gdk.Texture, error) {
326	file := gio.NewFileForURI(faviconURI)
327	if file == nil {
328		return nil, fmt.Errorf("invalid SVG favicon URI: %s", faviconURI)
329	}
330
331	data, _, err := file.LoadContents(ctx)
332	if err != nil {
333		return nil, fmt.Errorf("load SVG favicon: %w", err)
334	}
335
336	texture, err := webkit.TextureFromImageBytes(data)
337	if err != nil {
338		return nil, fmt.Errorf("decode SVG favicon: %w", err)
339	}
340
341	texture = fs.ensureTextureSize(texture)
342	if texture == nil {
343		return nil, fmt.Errorf("failed to scale SVG favicon")
344	}
345
346	return texture, nil
347}
348
349func (fs *FaviconService) lookupFaviconURI(ctx context.Context, canonicalURL, originalURL string) (string, error) {
350	type void struct{}
351	seen := make(map[string]void, 2)
352	candidates := make([]string, 0, 2)
353
354	addCandidate := func(val string) {
355		if val == "" {
356			return
357		}
358		if _, exists := seen[val]; exists {
359			return
360		}
361		seen[val] = void{}
362		candidates = append(candidates, val)
363	}
364
365	addCandidate(canonicalURL)
366	addCandidate(originalURL)
367
368	for _, candidate := range candidates {
369		entry, err := fs.dbQueries.GetHistoryEntry(ctx, candidate)
370		if err == nil {
371			if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" {
372				return entry.FaviconUrl.String, nil
373			}
374			continue
375		}
376		if !errors.Is(err, sql.ErrNoRows) {
377			logging.Error(fmt.Sprintf("[favicon] failed to query history entry for %s: %v", candidate, err))
378		}
379	}
380
381	if fs.faviconDB != nil {
382		for _, candidate := range candidates {
383			if uri := fs.faviconDB.FaviconURI(candidate); uri != "" {
384				return uri, nil
385			}
386		}
387	}
388
389	return "", fmt.Errorf("no favicon URI available for %s", canonicalURL)
390}
391
392func (fs *FaviconService) ensureTextureSize(texture *gdk.Texture) *gdk.Texture {
393	if texture == nil {
394		return nil
395	}
396
397	if texture.Width() == fs.targetSize && texture.Height() == fs.targetSize {
398		return texture
399	}
400
401	return webkit.ScaleTexture(texture, fs.targetSize, fs.targetSize)
402}
403
404func (fs *FaviconService) dispatchTexture(callback func(*gdk.Texture, error), texture *gdk.Texture, err error) {
405	_ = webkit.IdleAdd(func() bool {
406		callback(texture, err)
407		return false
408	})
409}
410
411func (fs *FaviconService) maybeExportTexture(pageURL string, texture *gdk.Texture) {
412	if !fs.enableExport || texture == nil {
413		return
414	}
415
416	exportPath := fs.getExportPath(pageURL)
417
418	_ = webkit.IdleAdd(func() bool {
419		if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil {
420			logging.Error(fmt.Sprintf("[favicon] Failed to export favicon PNG for %s: %v", pageURL, err))
421		}
422		return false
423	})
424}
425
426// GetFaviconPath returns the exported PNG path for CLI tools (dmenu, etc.)
427// This allows CLI tools to display favicons without WebKitGTK API access.
428func (fs *FaviconService) GetFaviconPath(pageURL string) (string, error) {
429	canonicalURL := parser.NormalizeHistoryURL(pageURL)
430	if canonicalURL == "" {
431		canonicalURL = pageURL
432	}
433
434	exportPath := fs.getExportPath(canonicalURL)
435	if info, err := os.Stat(exportPath); err == nil {
436		if time.Since(info.ModTime()) < 7*24*time.Hour {
437			return exportPath, nil
438		}
439	}
440
441	if !fs.enableExport {
442		return "", fmt.Errorf("favicon export disabled")
443	}
444
445	ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout)
446	defer cancel()
447
448	faviconURI, err := fs.lookupFaviconURI(ctx, canonicalURL, pageURL)
449	if err != nil {
450		return "", err
451	}
452
453	texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI)
454	if err != nil {
455		return "", err
456	}
457
458	if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil {
459		return "", fmt.Errorf("failed to save favicon PNG: %w", err)
460	}
461
462	return exportPath, nil
463}
464
465// getExportPath generates the export path for a page URL
466func (fs *FaviconService) getExportPath(pageURL string) string {
467	exportKey := canonicalExportURL(pageURL)
468	hash := fmt.Sprintf("%x", webkit.HashURL(exportKey))
469	return filepath.Join(fs.exportDir, hash+".png")
470}
471
472// shouldProcessURL checks if a URL should have its favicon processed
473func (fs *FaviconService) shouldProcessURL(pageURL string) bool {
474	parsedURL, err := webkit.ParseURL(pageURL)
475	if err != nil {
476		logging.Warn(fmt.Sprintf("[favicon] Invalid page URL: %s", pageURL))
477		return false
478	}
479
480	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
481		return false
482	}
483	if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
484		return false
485	}
486
487	return true
488}
489
490// isSVGFavicon checks if a favicon URI points to an SVG file.
491func (fs *FaviconService) isSVGFavicon(faviconURI string) bool {
492	if faviconURI == "" {
493		return false
494	}
495
496	parsedURL, err := webkit.ParseURL(faviconURI)
497	if err != nil {
498		return false
499	}
500
501	path := parsedURL.Path
502	if len(path) >= 4 {
503		ext := path[len(path)-4:]
504		if ext == ".svg" || ext == ".SVG" {
505			return true
506		}
507	}
508
509	return false
510}
511
512// CleanOldExports removes exported favicons older than 30 days
513func (fs *FaviconService) CleanOldExports() error {
514	entries, err := os.ReadDir(fs.exportDir)
515	if err != nil {
516		return err
517	}
518
519	cutoff := time.Now().Add(-30 * 24 * time.Hour)
520
521	for _, entry := range entries {
522		if entry.IsDir() {
523			continue
524		}
525
526		info, err := entry.Info()
527		if err != nil {
528			continue
529		}
530
531		if info.ModTime().Before(cutoff) {
532			if err := os.Remove(filepath.Join(fs.exportDir, entry.Name())); err != nil {
533				fmt.Fprintf(os.Stderr, "warning: failed to remove old favicon: %v\n", err)
534			}
535		}
536	}
537
538	return nil
539}
540
541// GetExportedFaviconPath returns the expected export path for a page URL.
542func GetExportedFaviconPath(dataDir, pageURL string) string {
543	exportDir := filepath.Join(dataDir, "webkit", "favicons-export")
544	exportKey := canonicalExportURL(pageURL)
545	hash := fmt.Sprintf("%x", webkit.HashURL(exportKey))
546	return filepath.Join(exportDir, hash+".png")
547}
548
549func canonicalExportURL(pageURL string) string {
550	parsed, err := webkit.ParseURL(pageURL)
551	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
552		normalized := parser.NormalizeHistoryURL(pageURL)
553		if normalized == "" {
554			return pageURL
555		}
556		parsed, err = webkit.ParseURL(normalized)
557		if err != nil || parsed.Scheme == "" || parsed.Host == "" {
558			return normalized
559		}
560	}
561
562	host := parsed.Host
563	if host == "" {
564		return fmt.Sprintf("%s://%s/", parsed.Scheme, parsed.Host)
565	}
566
567	return fmt.Sprintf("%s://%s/", parsed.Scheme, host)
568}

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