feat(favicon): add FaviconService with WebKit native integration
- New FaviconService using WebKitGTK FaviconDatabase as source of truth - Async texture loading via GetFaviconTexture(pageURL, callback) - SVG favicon rasterization to 32x32 PNG - Favicon export system for CLI access (dmenu) - Service interface hierarchy for lifecycle management - WebKit helper functions for texture scaling and PNG export
- commit
- a9d5550dadf254c4ee63fe2f0ebc73dd990a4d36
- author
- bnema · · 2025-10-18 10:54 UTC
- parent
- 2733b1b
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
3 files · +650 -0
A internal/services/favicon_service.go +454 -0
| @@ -0,0 +1,454 @@ | |||
| 1 | + | // Package services contains application services that orchestrate business logic. | |
| 2 | + | package services | |
| 3 | + | ||
| 4 | + | import ( | |
| 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. | |
| 23 | + | type 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 | + | ||
| 32 | + | const ( | |
| 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 | |
| 40 | + | func (fs *FaviconService) ServiceName() string { | |
| 41 | + | return "FaviconService" | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // NewFaviconService creates a new favicon service | |
| 45 | + | func 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. | |
| 84 | + | func (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 | + | // preloadFavicon renders and exports a favicon in the background | |
| 113 | + | // This is called immediately when OnFaviconChanged fires to ensure | |
| 114 | + | // favicons are ready for omnibox suggestions as soon as possible | |
| 115 | + | // propagateFaviconToDomain updates all history entries from the same domain | |
| 116 | + | // with the favicon. This ensures multiple paths on the same domain share the favicon | |
| 117 | + | // even if WebKit only signals it once per domain (KISS + DRY principle). | |
| 118 | + | func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) { | |
| 119 | + | ctx := context.Background() | |
| 120 | + | ||
| 121 | + | parsedURL, err := webkit.ParseURL(pageURL) | |
| 122 | + | if err != nil { | |
| 123 | + | return // Invalid URL, skip propagation | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | faviconNull := sql.NullString{String: faviconURI, Valid: true} | |
| 127 | + | ||
| 128 | + | // Search for exact domain match first | |
| 129 | + | domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true} | |
| 130 | + | entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000) | |
| 131 | + | if err != nil { | |
| 132 | + | log.Printf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err) | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Also search for www variant to handle www/non-www redirects | |
| 137 | + | // google.com should share favicon with www.google.com and vice versa | |
| 138 | + | var altPattern sql.NullString | |
| 139 | + | if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." { | |
| 140 | + | // Current is www.google.com, also search google.com | |
| 141 | + | hostWithoutWWW := parsedURL.Host[4:] | |
| 142 | + | altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true} | |
| 143 | + | } else { | |
| 144 | + | // Current is google.com, also search www.google.com | |
| 145 | + | altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true} | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000) | |
| 149 | + | entries = append(entries, altEntries...) | |
| 150 | + | ||
| 151 | + | // Update all entries that don't already have a favicon | |
| 152 | + | for _, entry := range entries { | |
| 153 | + | if entry.FaviconUrl.String == "" { | |
| 154 | + | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil { | |
| 155 | + | log.Printf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err) | |
| 156 | + | } else { | |
| 157 | + | log.Printf("[favicon] Propagated favicon to %s", entry.Url) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (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. | |
| 179 | + | func (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 | + | ||
| 210 | + | func (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 | + | ||
| 264 | + | func (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 | + | ||
| 288 | + | func (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 | + | ||
| 307 | + | func (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 | + | ||
| 319 | + | func (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 | + | ||
| 326 | + | func (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. | |
| 343 | + | func (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 | |
| 376 | + | func (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 | |
| 383 | + | func (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. | |
| 401 | + | func (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 | |
| 423 | + | func (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. | |
| 450 | + | func 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 | + | } | |
| @@ -0,0 +1,454 @@ | |||||
| 1 | + | // Package services contains application services that orchestrate business logic. | |||
| 2 | + | package services | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 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. | |||
| 23 | + | type 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 | + | ||||
| 32 | + | const ( | |||
| 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 | |||
| 40 | + | func (fs *FaviconService) ServiceName() string { | |||
| 41 | + | return "FaviconService" | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // NewFaviconService creates a new favicon service | |||
| 45 | + | func 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. | |||
| 84 | + | func (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 | + | // preloadFavicon renders and exports a favicon in the background | |||
| 113 | + | // This is called immediately when OnFaviconChanged fires to ensure | |||
| 114 | + | // favicons are ready for omnibox suggestions as soon as possible | |||
| 115 | + | // propagateFaviconToDomain updates all history entries from the same domain | |||
| 116 | + | // with the favicon. This ensures multiple paths on the same domain share the favicon | |||
| 117 | + | // even if WebKit only signals it once per domain (KISS + DRY principle). | |||
| 118 | + | func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) { | |||
| 119 | + | ctx := context.Background() | |||
| 120 | + | ||||
| 121 | + | parsedURL, err := webkit.ParseURL(pageURL) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return // Invalid URL, skip propagation | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | faviconNull := sql.NullString{String: faviconURI, Valid: true} | |||
| 127 | + | ||||
| 128 | + | // Search for exact domain match first | |||
| 129 | + | domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true} | |||
| 130 | + | entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000) | |||
| 131 | + | if err != nil { | |||
| 132 | + | log.Printf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err) | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Also search for www variant to handle www/non-www redirects | |||
| 137 | + | // google.com should share favicon with www.google.com and vice versa | |||
| 138 | + | var altPattern sql.NullString | |||
| 139 | + | if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." { | |||
| 140 | + | // Current is www.google.com, also search google.com | |||
| 141 | + | hostWithoutWWW := parsedURL.Host[4:] | |||
| 142 | + | altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true} | |||
| 143 | + | } else { | |||
| 144 | + | // Current is google.com, also search www.google.com | |||
| 145 | + | altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true} | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000) | |||
| 149 | + | entries = append(entries, altEntries...) | |||
| 150 | + | ||||
| 151 | + | // Update all entries that don't already have a favicon | |||
| 152 | + | for _, entry := range entries { | |||
| 153 | + | if entry.FaviconUrl.String == "" { | |||
| 154 | + | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil { | |||
| 155 | + | log.Printf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err) | |||
| 156 | + | } else { | |||
| 157 | + | log.Printf("[favicon] Propagated favicon to %s", entry.Url) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func (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. | |||
| 179 | + | func (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 | + | ||||
| 210 | + | func (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 | + | ||||
| 264 | + | func (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 | + | ||||
| 288 | + | func (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 | + | ||||
| 307 | + | func (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 | + | ||||
| 319 | + | func (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 | + | ||||
| 326 | + | func (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. | |||
| 343 | + | func (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 | |||
| 376 | + | func (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 | |||
| 383 | + | func (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. | |||
| 401 | + | func (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 | |||
| 423 | + | func (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. | |||
| 450 | + | func 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 | + | } | |||
A internal/services/service.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | // Package services defines the service interface and common service patterns | |
| 2 | + | package services | |
| 3 | + | ||
| 4 | + | // Service is the base interface that all application services must implement | |
| 5 | + | // This allows for consistent service management, lifecycle control, and dependency injection | |
| 6 | + | type Service interface { | |
| 7 | + | // ServiceName returns the unique identifier for this service | |
| 8 | + | // Used for logging, debugging, and service registration | |
| 9 | + | ServiceName() string | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // Initializable services can be initialized after construction | |
| 13 | + | type Initializable interface { | |
| 14 | + | Service | |
| 15 | + | // Initialize performs any setup required after construction | |
| 16 | + | // Returns an error if initialization fails | |
| 17 | + | Initialize() error | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Cleanable services can cleanup resources on shutdown | |
| 21 | + | type Cleanable interface { | |
| 22 | + | Service | |
| 23 | + | // Cleanup releases any resources held by the service | |
| 24 | + | // Should be idempotent and safe to call multiple times | |
| 25 | + | Cleanup() error | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // LifecycleService implements both Initializable and Cleanable | |
| 29 | + | // Use this for services that need both initialization and cleanup | |
| 30 | + | type LifecycleService interface { | |
| 31 | + | Initializable | |
| 32 | + | Cleanable | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | // Package services defines the service interface and common service patterns | |||
| 2 | + | package services | |||
| 3 | + | ||||
| 4 | + | // Service is the base interface that all application services must implement | |||
| 5 | + | // This allows for consistent service management, lifecycle control, and dependency injection | |||
| 6 | + | type Service interface { | |||
| 7 | + | // ServiceName returns the unique identifier for this service | |||
| 8 | + | // Used for logging, debugging, and service registration | |||
| 9 | + | ServiceName() string | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // Initializable services can be initialized after construction | |||
| 13 | + | type Initializable interface { | |||
| 14 | + | Service | |||
| 15 | + | // Initialize performs any setup required after construction | |||
| 16 | + | // Returns an error if initialization fails | |||
| 17 | + | Initialize() error | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Cleanable services can cleanup resources on shutdown | |||
| 21 | + | type Cleanable interface { | |||
| 22 | + | Service | |||
| 23 | + | // Cleanup releases any resources held by the service | |||
| 24 | + | // Should be idempotent and safe to call multiple times | |||
| 25 | + | Cleanup() error | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // LifecycleService implements both Initializable and Cleanable | |||
| 29 | + | // Use this for services that need both initialization and cleanup | |||
| 30 | + | type LifecycleService interface { | |||
| 31 | + | Initializable | |||
| 32 | + | Cleanable | |||
| 33 | + | } | |||
A pkg/webkit/favicon_helpers.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | /* | |
| 4 | + | #cgo pkg-config: webkitgtk-6.0 | |
| 5 | + | #include <webkit/webkit.h> | |
| 6 | + | */ | |
| 7 | + | import "C" | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "context" | |
| 11 | + | "crypto/md5" | |
| 12 | + | "fmt" | |
| 13 | + | "net/url" | |
| 14 | + | "runtime" | |
| 15 | + | "unsafe" | |
| 16 | + | ||
| 17 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 18 | + | "github.com/diamondburned/gotk4/pkg/core/gerror" | |
| 19 | + | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | |
| 20 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 21 | + | "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" | |
| 22 | + | gio "github.com/diamondburned/gotk4/pkg/gio/v2" | |
| 23 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // GdkTexture is an alias for gotk4's Texture type | |
| 27 | + | type GdkTexture = gdk.Texture | |
| 28 | + | ||
| 29 | + | // FaviconDatabase is an alias for WebKit's FaviconDatabase | |
| 30 | + | type FaviconDatabase = webkit.FaviconDatabase | |
| 31 | + | ||
| 32 | + | // SaveTextureAsPNG saves a GdkTexture as a PNG file at target size | |
| 33 | + | // WebKit provides GdkTexture which can be any format (ICO, PNG, JPEG, SVG) | |
| 34 | + | // This function normalizes everything to PNG at the target size (32x32) | |
| 35 | + | func SaveTextureAsPNG(texture *gdk.Texture, path string, targetSize int) error { | |
| 36 | + | if texture == nil { | |
| 37 | + | return fmt.Errorf("texture cannot be nil") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | width := texture.Width() | |
| 41 | + | height := texture.Height() | |
| 42 | + | ||
| 43 | + | // If texture needs scaling, create scaled version | |
| 44 | + | if width != targetSize || height != targetSize { | |
| 45 | + | texture = ScaleTexture(texture, targetSize, targetSize) | |
| 46 | + | if texture == nil { | |
| 47 | + | return fmt.Errorf("failed to scale texture") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Save texture as PNG using built-in method | |
| 52 | + | if !texture.SaveToPNG(path) { | |
| 53 | + | return fmt.Errorf("failed to save PNG to %s", path) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // ScaleTexture scales a texture to the target size using GdkPixbuf | |
| 60 | + | // Uses high-quality bilinear interpolation | |
| 61 | + | func ScaleTexture(texture *gdk.Texture, targetWidth, targetHeight int) *gdk.Texture { | |
| 62 | + | if texture == nil { | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Get PNG bytes from texture | |
| 67 | + | pngBytes := texture.SaveToPNGBytes() | |
| 68 | + | if pngBytes == nil { | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Load as pixbuf using memory input stream | |
| 73 | + | ctx := context.Background() | |
| 74 | + | stream := gio.NewMemoryInputStreamFromBytes(pngBytes) | |
| 75 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(ctx, stream) | |
| 76 | + | if err != nil || pixbuf == nil { | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Scale pixbuf | |
| 81 | + | scaled := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.InterpBilinear) | |
| 82 | + | if scaled == nil { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Convert back to texture | |
| 87 | + | return gdk.NewTextureForPixbuf(scaled) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // HashURL generates an MD5 hash of a URL for filename generation | |
| 91 | + | func HashURL(pageURL string) []byte { | |
| 92 | + | hash := md5.Sum([]byte(pageURL)) | |
| 93 | + | return hash[:] | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // ParseURL is a convenience wrapper around url.Parse | |
| 97 | + | func ParseURL(rawURL string) (*url.URL, error) { | |
| 98 | + | return url.Parse(rawURL) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // FaviconFinishSafe mirrors gotk4's FaviconFinish but guards against NULL returns. | |
| 102 | + | func FaviconFinishSafe(database *webkit.FaviconDatabase, result gio.AsyncResulter) (*gdk.Texture, error) { | |
| 103 | + | if database == nil || result == nil { | |
| 104 | + | return nil, fmt.Errorf("favicon finish: invalid arguments") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | cDatabase := (*C.WebKitFaviconDatabase)(unsafe.Pointer(glib.BaseObject(database).Native())) | |
| 108 | + | cResult := (*C.GAsyncResult)(unsafe.Pointer(glib.BaseObject(result).Native())) | |
| 109 | + | ||
| 110 | + | var cErr *C.GError | |
| 111 | + | cTexture := C.webkit_favicon_database_get_favicon_finish(cDatabase, cResult, &cErr) | |
| 112 | + | runtime.KeepAlive(database) | |
| 113 | + | runtime.KeepAlive(result) | |
| 114 | + | ||
| 115 | + | if cErr != nil { | |
| 116 | + | err := gerror.Take(unsafe.Pointer(cErr)) | |
| 117 | + | return nil, err | |
| 118 | + | } | |
| 119 | + | if cTexture == nil { | |
| 120 | + | return nil, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | object := coreglib.AssumeOwnership(unsafe.Pointer(cTexture)) | |
| 124 | + | casted := object.WalkCast(func(obj coreglib.Objector) bool { | |
| 125 | + | _, ok := obj.(gdk.Texturer) | |
| 126 | + | return ok | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | texturer, ok := casted.(gdk.Texturer) | |
| 130 | + | if !ok { | |
| 131 | + | return nil, fmt.Errorf("favicon finish: unexpected type %T", casted) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | texture := gdk.BaseTexture(texturer) | |
| 135 | + | if texture == nil { | |
| 136 | + | return nil, fmt.Errorf("favicon finish: texture unavailable") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return texture, nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // TextureFromImageBytes creates a texture from raw image bytes. | |
| 143 | + | // Supported formats depend on the available GdkPixbuf loaders (PNG, SVG, etc.). | |
| 144 | + | func TextureFromImageBytes(data []byte) (*gdk.Texture, error) { | |
| 145 | + | if len(data) == 0 { | |
| 146 | + | return nil, fmt.Errorf("image data is empty") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Convert []byte to glib.Bytes for gio API | |
| 150 | + | glibBytes := glib.NewBytes(data) | |
| 151 | + | stream := gio.NewMemoryInputStreamFromBytes(glibBytes) | |
| 152 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(context.Background(), stream) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, fmt.Errorf("load pixbuf: %w", err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | texture := gdk.NewTextureForPixbuf(pixbuf) | |
| 158 | + | if texture == nil { | |
| 159 | + | return nil, fmt.Errorf("create texture from pixbuf failed") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | return texture, nil | |
| 163 | + | } | |
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | /* | |||
| 4 | + | #cgo pkg-config: webkitgtk-6.0 | |||
| 5 | + | #include <webkit/webkit.h> | |||
| 6 | + | */ | |||
| 7 | + | import "C" | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "crypto/md5" | |||
| 12 | + | "fmt" | |||
| 13 | + | "net/url" | |||
| 14 | + | "runtime" | |||
| 15 | + | "unsafe" | |||
| 16 | + | ||||
| 17 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |||
| 18 | + | "github.com/diamondburned/gotk4/pkg/core/gerror" | |||
| 19 | + | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | |||
| 20 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |||
| 21 | + | "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" | |||
| 22 | + | gio "github.com/diamondburned/gotk4/pkg/gio/v2" | |||
| 23 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // GdkTexture is an alias for gotk4's Texture type | |||
| 27 | + | type GdkTexture = gdk.Texture | |||
| 28 | + | ||||
| 29 | + | // FaviconDatabase is an alias for WebKit's FaviconDatabase | |||
| 30 | + | type FaviconDatabase = webkit.FaviconDatabase | |||
| 31 | + | ||||
| 32 | + | // SaveTextureAsPNG saves a GdkTexture as a PNG file at target size | |||
| 33 | + | // WebKit provides GdkTexture which can be any format (ICO, PNG, JPEG, SVG) | |||
| 34 | + | // This function normalizes everything to PNG at the target size (32x32) | |||
| 35 | + | func SaveTextureAsPNG(texture *gdk.Texture, path string, targetSize int) error { | |||
| 36 | + | if texture == nil { | |||
| 37 | + | return fmt.Errorf("texture cannot be nil") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | width := texture.Width() | |||
| 41 | + | height := texture.Height() | |||
| 42 | + | ||||
| 43 | + | // If texture needs scaling, create scaled version | |||
| 44 | + | if width != targetSize || height != targetSize { | |||
| 45 | + | texture = ScaleTexture(texture, targetSize, targetSize) | |||
| 46 | + | if texture == nil { | |||
| 47 | + | return fmt.Errorf("failed to scale texture") | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Save texture as PNG using built-in method | |||
| 52 | + | if !texture.SaveToPNG(path) { | |||
| 53 | + | return fmt.Errorf("failed to save PNG to %s", path) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // ScaleTexture scales a texture to the target size using GdkPixbuf | |||
| 60 | + | // Uses high-quality bilinear interpolation | |||
| 61 | + | func ScaleTexture(texture *gdk.Texture, targetWidth, targetHeight int) *gdk.Texture { | |||
| 62 | + | if texture == nil { | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Get PNG bytes from texture | |||
| 67 | + | pngBytes := texture.SaveToPNGBytes() | |||
| 68 | + | if pngBytes == nil { | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Load as pixbuf using memory input stream | |||
| 73 | + | ctx := context.Background() | |||
| 74 | + | stream := gio.NewMemoryInputStreamFromBytes(pngBytes) | |||
| 75 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(ctx, stream) | |||
| 76 | + | if err != nil || pixbuf == nil { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Scale pixbuf | |||
| 81 | + | scaled := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.InterpBilinear) | |||
| 82 | + | if scaled == nil { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Convert back to texture | |||
| 87 | + | return gdk.NewTextureForPixbuf(scaled) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // HashURL generates an MD5 hash of a URL for filename generation | |||
| 91 | + | func HashURL(pageURL string) []byte { | |||
| 92 | + | hash := md5.Sum([]byte(pageURL)) | |||
| 93 | + | return hash[:] | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // ParseURL is a convenience wrapper around url.Parse | |||
| 97 | + | func ParseURL(rawURL string) (*url.URL, error) { | |||
| 98 | + | return url.Parse(rawURL) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // FaviconFinishSafe mirrors gotk4's FaviconFinish but guards against NULL returns. | |||
| 102 | + | func FaviconFinishSafe(database *webkit.FaviconDatabase, result gio.AsyncResulter) (*gdk.Texture, error) { | |||
| 103 | + | if database == nil || result == nil { | |||
| 104 | + | return nil, fmt.Errorf("favicon finish: invalid arguments") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | cDatabase := (*C.WebKitFaviconDatabase)(unsafe.Pointer(glib.BaseObject(database).Native())) | |||
| 108 | + | cResult := (*C.GAsyncResult)(unsafe.Pointer(glib.BaseObject(result).Native())) | |||
| 109 | + | ||||
| 110 | + | var cErr *C.GError | |||
| 111 | + | cTexture := C.webkit_favicon_database_get_favicon_finish(cDatabase, cResult, &cErr) | |||
| 112 | + | runtime.KeepAlive(database) | |||
| 113 | + | runtime.KeepAlive(result) | |||
| 114 | + | ||||
| 115 | + | if cErr != nil { | |||
| 116 | + | err := gerror.Take(unsafe.Pointer(cErr)) | |||
| 117 | + | return nil, err | |||
| 118 | + | } | |||
| 119 | + | if cTexture == nil { | |||
| 120 | + | return nil, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | object := coreglib.AssumeOwnership(unsafe.Pointer(cTexture)) | |||
| 124 | + | casted := object.WalkCast(func(obj coreglib.Objector) bool { | |||
| 125 | + | _, ok := obj.(gdk.Texturer) | |||
| 126 | + | return ok | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | texturer, ok := casted.(gdk.Texturer) | |||
| 130 | + | if !ok { | |||
| 131 | + | return nil, fmt.Errorf("favicon finish: unexpected type %T", casted) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | texture := gdk.BaseTexture(texturer) | |||
| 135 | + | if texture == nil { | |||
| 136 | + | return nil, fmt.Errorf("favicon finish: texture unavailable") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return texture, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // TextureFromImageBytes creates a texture from raw image bytes. | |||
| 143 | + | // Supported formats depend on the available GdkPixbuf loaders (PNG, SVG, etc.). | |||
| 144 | + | func TextureFromImageBytes(data []byte) (*gdk.Texture, error) { | |||
| 145 | + | if len(data) == 0 { | |||
| 146 | + | return nil, fmt.Errorf("image data is empty") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Convert []byte to glib.Bytes for gio API | |||
| 150 | + | glibBytes := glib.NewBytes(data) | |||
| 151 | + | stream := gio.NewMemoryInputStreamFromBytes(glibBytes) | |||
| 152 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(context.Background(), stream) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, fmt.Errorf("load pixbuf: %w", err) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | texture := gdk.NewTextureForPixbuf(pixbuf) | |||
| 158 | + | if texture == nil { | |||
| 159 | + | return nil, fmt.Errorf("create texture from pixbuf failed") | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | return texture, nil | |||
| 163 | + | } | |||