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}