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}