v0.14.1 0da30ba

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

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