v0.12.1 1453d21

favicon_service.go RawHistoryFolder

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

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