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}