detached bf6a2bf

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
bf6a2bf052ad271dada09ecda97b5b0e6cb91d40
author
bnema · · 2025-10-18 10:54 UTC
parent
c41f66d
signature
unsigned
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close