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}