v0.13.0 976176a

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

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